Prologue
**Author's Note - I waited until I had both parts of this post put together before posting, which gave me some time to read a little in some of the other chapters. Things seem to have improved in readability some in them. I want to emphasize again that I'm not here to bash the book, rather to provide some of the missing information.
About the time I started this blog, I purchased an AWESOME programming book - ASP.Net 2.0 Website Programming: Problem - Design - Solution. A very highly praised book, and for good reason, it's fantastic! (in fact, do you see a resemblance between my blog sections and the the book's title?) Recently a newer ASP.Net 3.5 edition was announced, and I practically pre-ordered it (which I've never done with any book before) with the anticipation that it would be as good as the previous edition. Quite frankly, having read the first 3 chapters, I am pretty disappointed. Specifically, I've found that rather than being strictly Problem Design Solution, it's kind of Problem, Design/Solution, not quite enough Solution. The code for the solution has been kind of hard to follow since it is SO mixed into the explanation given in the design section. Also I've had a hard time figuring out where the author intends to put code (i.e. what files and in what folders). I have found that I'm not the only one with this problem, so something's not quite right. In the interest of fairness, I still haven't made it past chapter 3 so maybe it gets better in chapter 4 or 5, however I'd like to get this working before I move on to subsequent chapters.
I think that like many others in the book's official forum, I was expecting (like in the previous version) to walk trough the book and create a working website. The author made it clear in the forums that this isn't the case, "the book is not intended to be a follow along and create the site step by step book. There's not enough room..." Personally I disagree. I feel the book could have done exactly that while remaining a reasonable length. Also I would have liked to see the project code download have a project for each chapter (where each chapter's project takes all the code from previous chapters and builds from there), so that we can watch the progression of the code as we go through the book.
Having said that, I'm not here specifically to bash the newer version of the book, rather I thought I'd fill in some of the gaps and document how to do some of the things I felt were missing or poorly described. I hope to fill in some of the gaps - like what file to put xyz code in, and in what folder/solution to put that code file in. I expect that I may do a couple of posts related to this topic (in fact this chapter. (*Note: Since I'm already past chapter 2, so I don't expect that I'll go back and fill in any holes there...))
Problem
When I finished reading chapter 3, I found that using the code in the Solution section I had some major holes in my solution. Specifically, I found the following that I will address in this post (others will follow). I needed:
- A table definition for the SiteMap data (what are the data types?).
- To generate an Entity Model for the SiteMap data.
- A Base Repository class.
- Some code for our Helpers class module.
- A SiteMap repository class.
- An IBaseEntity interface.
- A partial class extending our SiteMapInfo object generated by out Entity Model wizard.
- A partial class extending our SiteMapEntity generated by our Entity Model wizard.
- A BeerHouseDataException class.
Hopefully I'll be able to provide a little of this for you so that you don't have to go around figuring out stuff only by looking through the source code files like I have.
Solution
Let's start at the top and work down (or at the beginning and work backwards?)
A table Definition for the SiteMap Data
One of the first things that sticks out is that there isn't really a definition of the SiteMap table in the database. There was mention in the forums that there are pictures of the SiteMap table in the book on pages 80 and 125, but this is lacking in some detail as it doesn't provide datatypes, just the column names. I found that the database files were included in a separate download and that they are SQL Server 2008 version (meaning they can't be run with SQL Server 2005). SO if you want to access the files yourself, you can:
- Download and install SQL Server 2008 Express.
- Download and install SQL Server Management Studio Express 2008, you can't connect using the 2005 version (which is much harder than it sounds - and MUCH harder than it ought to be - just read the community content here).
- Attach your DB files and then find the definitions.
Or, you can just use this script to generate your table (I'm using SQL Server 2005 instead of 2008 on my machine) or to at least get your type definitions etc. I generated this script from the database in the code download. While the code download is SQL Server 2008, I found that this script successfully creates the table in my 2005 Server. The SiteMap table is defined as follows:
USE [TheBeerHouseVB]
GO
/****** Object: Table [dbo].[SiteMap] Script Date: 01/07/2010 16:41:55 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[SiteMap](
[SiteMapId] [int] IDENTITY(1,1) NOT NULL,
[URL] [nvarchar](500) NOT NULL,
[RealURL] [nvarchar](500) NOT NULL,
[Title] [nvarchar](256) NOT NULL,
[Keywords] [nvarchar](500) NOT NULL,
[Description] [nvarchar](200) NOT NULL,
[Roles] [nvarchar](100) NULL,
[Parent] [int] NOT NULL,
[URLId] [int] NOT NULL,
[URLType] [nvarchar](50) NULL,
[NodeType] [int] NOT NULL,
[SortOrder] [int] NOT NULL,
[Active] [bit] NOT NULL,
[DateAdded] [datetime] NOT NULL,
[AddedBy] [nvarchar](50) NOT NULL,
[DateUpdated] [datetime] NOT NULL,
[UpdatedBy] [nvarchar](50) NOT NULL,
CONSTRAINT [PK_SiteMap] PRIMARY KEY CLUSTERED
(
[SiteMapId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[SiteMap] ADD CONSTRAINT [DF_SiteMap_Parent] DEFAULT ((0)) FOR [Parent]
GO
ALTER TABLE [dbo].[SiteMap] ADD CONSTRAINT [DF_SiteMap_URLId] DEFAULT ((0)) FOR [URLId]
GO
ALTER TABLE [dbo].[SiteMap] ADD CONSTRAINT [DF_SiteMap_NodeType] DEFAULT ((1)) FOR [NodeType]
GO
ALTER TABLE [dbo].[SiteMap] ADD CONSTRAINT [DF_SiteMap_SortOrder] DEFAULT ((100)) FOR [SortOrder]
GO
ALTER TABLE [dbo].[SiteMap] ADD CONSTRAINT [DF_SiteMap_Active] DEFAULT ((1)) FOR [Active]
GO
ALTER TABLE [dbo].[SiteMap] ADD CONSTRAINT [DF_SiteMap_DateAdded] DEFAULT (getdate()) FOR [DateAdded]
GO
ALTER TABLE [dbo].[SiteMap] ADD CONSTRAINT [DF_SiteMap_DateUpdated] DEFAULT (getdate()) FOR [DateUpdated]
GO
Generate an Entity Model for the SiteMap Data
I actually found that if I went back and implemented some the DESIGN section, there is a pretty decent walk through of creating the Entity Model. There are a few missing pieces I'll add though, and I'll try to point those out as I go (for future reference, these will be marked with an *Gap-Filled* designation).
First, where should the Entity Model go? I found that the author has this as part of the BLL DLL project (say that 10 times fast). Specifically, it goes in a folder called 'Navigation'. So to create our SiteMap Entity Model, do the following:
- *Gap-Filled* Add a new folder to your TBHBLL project's root node and name this folder 'Navigation'.
- Right-Click the Navigation folder and select 'Add'->'New Item'.
- From the list select 'ADO.Net Entity Data Model (it may be easier to find if you select 'Data' from the list on the left). Name it 'SiteMapModel' and click 'Add'.
- Select 'Generate from database' and click Next. (Note: originally I created a database in my App_Data folder, but found that making any changes to the DB was a major pain in the neck so I opted to nuke it an put it in an external DB, just for sake of easy of working with the DB).
- Select your DB, or if you aren't using the App_Data folder to house your data, click 'New Connection...'. From there, you can create your data connection to your DB. Notice that this creates a connection string for your entity. You'll want to copy this for later use (put it somewhere safe, make your life easier).
- Before clicking next, you'll want to change the setting at the bottom 'Save entity connection settings in app.config as:' to 'SiteMapEntities'. Then click next.
- Now you should select the 'SiteMap' table as the object to include in your model and change the 'Model Namespace' to 'SiteMapModel'.
- Click Finish.
- Once the model is created, it will open a new 'SiteMapModel.edmx' file. We want to make a couple changes here.
- We want to change the name of our model to 'SiteMapInfo' rather than 'SiteMap' so as to
not be to be less confusing. Double click the name 'SiteMap' and rename it to 'SiteMapInfo' (alternately, you can select it and use the Properties window). - Select the 'Active' DB column down in the list and select the properties window. Change the 'Default Value' to 'True'.
You'll see that this basically creates a file containing two classes: 1. your actual entity object class (SiteMapInfo) and your Data Access Layer class (SiteMapEntities). We'll be extending BOTH these classes later on in this post. Go ahead and close your new SiteMapModel.edmx file and any of the new property windows that you don't want lingering around (I set them to autohide).
A BaseRepository Class
This one is a bit more tricky. I don't feel that it was fully fleshed out in the book. Looking through the source code and through the book, we find that all the repositories we'll be creating inherit from a BaseRepository class. Let's begin by creating this class. Start by creating a BaseRepository file (on page 89):
- In the root of your BLL project, create a new folder called 'Context'.*Gap-Filled*
- Add a new class to your 'Context' folder and name it 'BaseRepository.vb'.
- We need to change the class to be an interface so, change the class definition clause from 'Public Class BaseRepository' by adding the 'MustInherit' keyword.
- Make the class implement the IDisposable interface by adding 'Implements IDisposable' after the definition.
- When you press enter, you'll notice (at least I did) that it actually fleshes out the 'Dispose' subroutines. According to the book, we want those to be stubs only. As we'll define these each time in our repositories. To do this,
- *Gap-Filled* Add the 'MustOverride' keyword to the subroutine's first line. When you do that, all the subsequent stuff in the subroutine will become extraneous and can be removed so that the subroutine is ONLY one line, just defining it. The class should look like this (note: I've added the imports that we'll need before we're done already):
Imports System.Web
Imports System.Web.Caching
Imports System.Security.Principal
Imports System.Configuration
Namespace BLL
Public MustInherit Class BaseRepository
Implements IDisposable
Private disposedValue As Boolean = False ' To detect redundant calls
' IDisposable
Protected MustOverride Sub Dispose(ByVal disposing As Boolean)
#Region " IDisposable Support "
Public MustOverride Sub Dispose() Implements IDisposable.Dispose
#End Region
End Class
End Namespace
- *Gap-Filled* If you're continuing to follow in the book, skip the stuff at the bottom of page 89-90. That's that's an example of what we'll do when we inherit from the BaseRepository. Skip down to the 2nd code listing and let's continue adding to our class. Next we add a couple of constants:
Public Const DefPageSize As Integer = 50
Protected Const MAXROWS As Integer = Integer.MaxValue
- Then we want to add some properties that will be part of each of our repositories:
'These need to be definable in config and stored in app cache
Private _enableCaching As Boolean = True
Private _cacheDuration As Integer = 0 Public
Property EnableCaching() As Boolean
Get
Return _enableCaching
End Get
Set(ByVal value As Boolean)
_enableCaching = value
End Set
End Property
Public Property CacheDuration() As Integer
Get
Return _cacheDuration
End Get
Set(ByVal value As Integer)
_cacheDuration = value
End Set
End Property
Private _cacheKey As String = "CacheKey"
Public Property CacheKey() As String
Get
Return _cacheKey
End Get
Set(ByVal value As String)
_cacheKey = value
End Set
End Property
- Somehow, I thought that the code section at the bottom of page 91 was a demonstration of using the Cache code, however upon further investigation, I was incorrect. It also belongs to our BaseRepository class. Add the following to your class:
Protected Shared ReadOnly Property Cache() As Cache
Get
Return HttpContext.Current.Cache
End Get
End Property
Protected Shared Sub CacheData(ByVal key As String, _
ByVal data As Object, ByVal iDuration As Integer)
If Not IsNothing(data) Then
Cache.Insert(key, data, Nothing, _
DateTime.Now.AddSeconds(iDuration), TimeSpan.Zero)
End If
End Sub
Protected Sub PurgeCacheItems(ByVal prefix As String)
prefix = prefix.ToLower()
Dim itemsToRemove As New List(Of String)
Dim enumerator As IDictionaryEnumerator = Cache.GetEnumerator()
While enumerator.MoveNext
If enumerator.Key.ToString().ToLower().StartsWith(prefix) Then
itemsToRemove.Add(enumerator.Key.ToString())
End If
End While
For Each itemToRemove As String In itemsToRemove
Cache.Remove(itemToRemove)
Next
End Sub
- One note of deviation from the book: if you notice, the CacheData method takes an addition parameter not mentioned in the book. The code download adds an integer parameter called vDuration (which I changed to iDuration). This is used when caching the data to indicate how long it should live. *Gap-Filled*
We then add the connectionString stuff to our class:
Private _connectionString As String = "Set the ConnectionString"
Public Property ConnectionString() As String
Get
Return _connectionString
End Get
Set(ByVal value As String)
_connectionString = value
End Set
End Property
Protected Function GetActualConnectionString() As String
Return ConfigurationManager.ConnectionStrings _
(ConnectionString).ConnectionString
End Function
Some Code for our Helpers Class
If you follow the book, you'll notice a section in the middle of page 93 with code for three properties (CurrentUser, CurrentUserName and CurrentUserIP). There is a discrepancy between this code from the book and the code from the download. Basically the code here in the book belongs in our Helpers class. The repository DOES define these three properties, however, they call to the Helpers class to retrieve the information. I am unsure why the BaseRepository would need this if we already define them in the Helpers class (and in fact GET the data directly from the Helpers class), but they are in the BaseRepository.
All discussion of that aside, the code in the middle of page 93, at least according to the code download actually goes in our Helpers class (created in chapter 2). Take a break from your BaseRepository class and add the following to your Helpers class *Gap-Filled*:
Imports System.Security.Principal
Protected Shared ReadOnly Property CurrentUser() As IPrincipal
Get
Return HttpContext.Current.User
End Get
End Property
Protected Shared ReadOnly Property CurrentUserName() As String
Get
Dim userName As String = String.Empty
If CurrentUser.Identity.IsAuthenticated Then
userName = CurrentUser.Identity.Name
End If
Return userName
End Get
End Property
Protected Shared ReadOnly Property CurrentUserIP() As String
Get
Return HttpContext.Current.Request.UserHostAddress
End Get
End Property
Back to the BaseRepository Class
We aren't done with the base repository class, we need to go back to the class now that we've finished our Helpers module diversion. Beginning again with the function at the bottom of page 93, we'll add the following to our BaseRepository class *Gap-Filled*:
Protected Shared Function EncodeText(ByVal content As String) As String
content = HttpUtility.HtmlEncode(content)
content = content.Replace(" ", " ").Replace("\n", "<br>")
Return content
End Function
Protected Shared Function ConvertNullToEmptyString _
(ByVal input As String) As String
If String.IsNullOrEmpty(input) Then
Return String.Empty
Else
Return input
End If
End Function
That fooled me. Reading the book I thought these two should go into the helpers module, but looking trough the code in the files, I found that they actually belong in the BaseRepository class.
I also found reference to a couple methods in the BaseRepository class that I couldn't readily find in the text. Once such is the GetRandItem function. In searching out references to the BaseRepository class in other sections of the book I found a reference UTILZING this function, but not DEFINING it. As such, I'll take the time to define it here so we'll have it in our BaseRepository class. You'll recognize that it's bascially just a random number generator. Add the following function to your BaseRepository class:
Public Function GetRandItem(ByVal min As Integer, ByVal max As Integer) As Integer
Randomize()
Return Int((max - min) * Rnd())
End Function
Finally, we can add (and I'd consider it optional) the three properties that call our Helpers class. If you want, add the following to your BaseRepository class:
Protected Shared ReadOnly Property CurrentUser() As IPrincipal
Get
Return Helpers.CurrentUser
End Get
End Property
Protected Shared ReadOnly Property CurrentUserName() As String
Get
Return Helpers.CurrentUserName
End Get
End Property
Protected Shared ReadOnly Property CurrentUserIP() As String
Get
Return Helpers.CurrentUserIP
End Get
End Property
Again, you'll notice that they just call the properties we called in the Helpers class.
Creating a SiteMapRepository Class
As far as I can tell, the code for the SiteMapRepository is missing from the book in a major way. The example in this section gives us some sample code for the ArticlesRepository and its related classes (categories etc) but not for the SiteMapRepository class itself. We'll jump around a little to create this and hopefully come up with something that works. Let's get started:
- We want to create a SiteMapRepository Class, this should be created in the Navigation folder *Gap-Filled*.
- This class should inherit the BaseRepository class that we just created, which will automatically give us stubs for the Dispose methods. To complete the stubs, we should go back to page 89 and pick out from the last code block to fill in our stubs. Add the following to your SiteMapRepository class's Dispose() methods:
Public Overloads Overrides Sub Dispose()
Dispose(True)
GC.SuppressFinalize(Me)
End Sub
Private disposedValue As Boolean = False
Protected Overloads Overrides Sub Dispose(ByVal disposing As Boolean)
If Not Me.disposedValue Then
If disposing Then
If IsNothing(_SiteMapctx) = False Then
_SiteMapctx.Dispose()
End If
End If
End If
Me.disposedValue = True
End Sub
Two items of note: 1. There is a private variable declared OUTSIDE our methods we need to create: disposedValue. 2. We'll get an undefined error regarding the _SiteMapctx object referenced in the 2nd dispose method, we need to define this.
- Do do this, we can examine the code from the ArticlesRespoitory on page 94 and modify it to be our SiteMapRespository code. Add the following to your SiteMapRepository class.
Private _SiteMapctx As SiteMapEntities
Public Property SiteMapctx() As SiteMapEntities
Get
If IsNothing(_SiteMapctx) Then
_SiteMapctx = New SiteMapEntities _
(GetActualConnectionString())
End If
Return _SiteMapctx
End Get
Set(ByVal value As SiteMapEntities)
_SiteMapctx = value
End Set
End Property
- At this point, the book examines the contents of a entity (see page 95-98). This helps us better understand what is going on in side the entity class. Uh, so were do you find this code? (I'm digressing a little, but the code can be found in the designer.vb file for your model.edmx file. In VS.Net, this file is hidden, you have to select the button at the top of the Solution Explorer to view all files to see this file. When you select it, then you'll see a plus in front of your model.edmx file. Clicking it reveals the model.designer.vb file. This is where the content of the 'entity' class guts discussed in these pages resides *Gap-Filled*).
- There is also a discussion of the IBaseEntity interface inserted in page 98. I'll talk about that in a minute, but for the time being, let's stick with our repository just for the sake of keeping things together.
- So, we also need a constructor for our SiteMapRepository, this also is somewhat tucked away, it is in the BaseRespository section of the book. If you reference pg 92, you'll see a code block at the bottom of the page containing the constructors for our SiteMapRepository. Add the following to your SiteMapRespository.vb file *Gap-Filled*:
Public Sub New(ByVal sConnectionString As String)
ConnectionString = sConnectionString
CacheKey = "SiteMap"
End Sub
Public Sub New()
ConnectionString = Globals.Settings.DefaultConnectionStringName
CacheKey = "SiteMap"
End Sub
- The next piece of the repository that I found was on page 138. There is a GetSiteMapInfoByURL function that is defined there that we can add to our repository. Add the following code to your repository:
Public Function GetSiteMapInfoByURL(ByVal URL As String) _
As SiteMapInfo
Return (From lai In SiteMapctx.SiteMapInfoSet _
Where lai.URL = URL).FirstOrDefault()
End Function
- That leaves quite a few of the methods undefined (as much because we don't know what they are as anything else). So I began a search through chapter three to see if I can find any more of these methods lurking around. I found one on page 73, funny enough though, this method is added upon when we get to page 101 when we add the caching bits into it. I'll just go straight to this piece on page 101 and do it all. Add the following to your repository (note there are discrepancies between the project download code and the code in the book - such as the addition of CacheDuration):
Private key as String = "SiteMapNodes"
Public Function GetSiteMapNodes() As List(Of SiteMapInfo)
Dim lSiteMapNodes As List(Of SiteMapInfo)
If EnableCaching AndAlso Not IsNothing(Cache(key)) Then
lSiteMapNodes = CType(Cache(key), List(Of SiteMapInfo))
End If
SiteMapctx.SiteMapInfoSet.MergeOption = _
Objects.MergeOption.NoTracking
lSiteMapNodes = (From lSiteMapNode In _
SiteMapctx.SiteMapInfoSet Order By _
lSiteMapNode.SortOrder).ToList()
If EnableCaching Then
CacheData(key, lSiteMapNodes, CacheDuration)
End If
Return lSiteMapNodes
End Function
- And, that's about all I can find regarding the SiteMapRespository's methods in the book. So I was forced to find the rest of them in the code download. Really, I think it's rather unfortunate the book doesn't have any of the rest of the code, but here's what I found to add *Gap-Filled*:
Private tsnKey As String = "TopSiteMapNodes"
Public Function GetActiveSiteMapNodes() As List(Of SiteMapInfo)
Dim lSiteMapNodes As List(Of SiteMapInfo)
Dim lActiveSiteMapKey As String = CacheKey & "_Active"
If MyBase.EnableCaching AndAlso _
Not IsNothing(Cache(lActiveSiteMapKey)) Then
lSiteMapNodes = CType(Cache(lActiveSiteMapKey), _
List(Of SiteMapInfo))
Else
lSiteMapNodes = (From lSiteMapNode In _
SiteMapctx.SiteMapInfoSet _
Where lSiteMapNode.Active = True Order By _
lSiteMapNode.SortOrder).ToList()
If MyBase.EnableCaching Then
CacheData(lActiveSiteMapKey, lSiteMapNodes, CacheDuration)
End If
End If
Return lSiteMapNodes
End Function
Public Function GetTopSiteMapNodes() As List(Of SiteMapInfo)
Dim lSiteMapNodes As List(Of SiteMapInfo)
If Not IsNothing(Cache(tsnKey)) Then
lSiteMapNodes = CType(Cache(tsnKey), List(Of SiteMapInfo)
Else
lSiteMapNodes = (From lSiteMapNode In _
SiteMapctx.SiteMapInfoSet _
Where lSiteMapNode.Active = True And _
lSiteMapNode.Parent = 0 _
Order By lSiteMapNode.SortOrder).ToList()
End If
CacheData(tsnKey, lSiteMapNodes, CacheDuration)
Return lSiteMapNodes
End Function
Public Function GetSiteMap() As List(Of SiteMapInfo)
Return (From lSiteMap In SiteMapctx.SiteMapInfoSet _
Where lSiteMap.Active = True).ToList()
End Function
Public Function GetSiteMapByID(ByVal SiteMapId As Integer) _
As SiteMapInfo
Return (From lai In SiteMapctx.SiteMapInfoSet _
Where lai.SiteMapId = SiteMapId).FirstOrDefault
End Function
Public Function GetSiteMapInfoByRealURL(ByVal URL As String) _
As SiteMapInfo
Return (From lsmi In SiteMapctx.SiteMapInfoSet _
Where lsmi.RealURL = URL).FirstOrDefault()
End Function
Public Function GetSiteMapCount() As Integer
Return (From lai In SiteMapctx.SiteMapInfoSet).Count()
End Function
Public Function AddSiteMap(ByVal vSiteMap As SiteMapInfo) _
As SiteMapInfo
Try
If vSiteMap.EntityState = EntityState.Detached Then
SiteMapctx.AddToSiteMapInfoSet(vSiteMap)
End If
MyBase.PurgeCacheItems(CacheKey)
Return If(SiteMapctx.SaveChanges > 0, vSiteMap, Nothing)
Catch ex As Exception
ActiveExceptions.add(CacheKey & "_" & vSiteMap.SiteMapId, ex)
Return Nothing
End Try
End Function
Public Function DeleteSiteMap(ByVal vSiteMap As SiteMapInfo) As Boolean
Try
SiteMapctx.DeleteObject(vSiteMap)
SiteMapctx.SaveChanges()
MyBase.PurgeCacheItems(CacheKey)
Return True
Catch ex As Exception
Return False
End Try
End Function
- Here's the definitive list of methods that we should have that I found from the project files (in no particular order):
GetSiteMapInfoByURL()
GetSiteMapNodes()
GetActiveSiteMapNodes()
GetTopSiteMapNodes()
GetSiteMap()
GetSiteMapByID()
GetSiteMapInfoByRealURL()
GetSiteMapCout()
AddSiteMap()
DeleteSiteMap()
- Finally, if you notice you probably have an error about ActiveExceptions not being defined (in your AddSiteMap() function). This needs defined, however it is defined in the BaseRepository.vb class. So we'll go back one more time to our BaseRepository.vb class.
Back to the BaseRepository Class (Again)
We've got one more property to add to our BaseRepository class so that we can track exceptions that occur. While we are using this property here in Chapter 3 in our SiteMapRepository class, it isn't actually defined until Chapter 5. The definition of this property is found on page 262 (*Gap-Filled*). Add the following to your BaseRepository.vb class:
Private _activeExceptions As Dictionary(Of String, Exception)
Public Property ActiveExceptions() As Dictionary(Of String, Exception)
Get
If IsNothing(_activeExceptions) Then _activeExceptions = _
New Dictionary(Of String, Exception)
Return _activeExceptions
End Get
Set(ByVal value As Dictionary(Of String, Exception))
_activeExceptions = value
End Set
End Property
The IBaseEntity Interface
Tucked nicely away in the discussion of entities the definition of an interface for an entity. If you blink you'll miss it. We'll use this later as the interface when we extend our entity class. To create this interface, do the following:
- Create a new 'Interfaces' folder in your BLL project *Gap-Filled*.
- In this folder add a new class and name it 'IBaseEntity'.
- Change the contents of the file to read as follows *Gap-Filled*:
Namespace BLL
Public Interface IBaseEntity
ReadOnly Property IsValid() As Boolean
Property SetName() As String
Property IsDirty() as Boolean
ReadOnly Property CanEdit() As Boolean
ReadOnly Property CanRead() As Boolean
ReadOnly Property CanDelete() As Boolean
ReadOnly Property CanAdd() As Boolean
End Interface
End Namespace
- Notice that we're adding an additional Property that wasn't defined in the chapter, IsDirty. This is defined in the project files that were downloaded.
- That's it for IBaseEntity, we'll use it when we create entities (for example, an article, or category class). When we create the entity class, they will implement this interface. If you want to see an example of how it is implemented, look at the code block at the top of pg. 99.
Extending the SiteMapInfo Class (Originally Generated in the Model)
It took me a long time to understand what's going one with some of the files in the final solution project and how they relate to each other. This piece is one of them. If you download the final project files, you'll find a file 'SiteMapNode.vb' in the Navigation folder. It took me forever to understand the relationship of this file to ANY of the others. It is an extension of the partial SiteMapInfo class that was generated in the Model file (SiteMapModel.Desiger.VB). You'll also notice that we also extend the SiteMapEntities class that is ALSO in the Model file. As I understand the difference between this two pieces, the SiteMapEntities Class is the ObjectContext class, or the data plumbing (the part that talks to the database - the Data Access Layer if you will). And the SiteMapInfo class is the actual object (or instance of an entity) created by one of the records in our database. BOTH get extended and in different code files. So with no further ado, let's extend our SiteMapInfo class to use our IBaseEntity and add definitions for the items that we created in our interface. I couldn't find code for these properties in the book, with the exception of the IsValid property I found on page 99. The rest of this came from the finished project file.
So let's do the following:
- Create a new class module in your Navigation folder and name it 'SiteMapNode.vb'.
- We'll want to modify the name of the class so that it connects with the SiteMapInfo partial class, so rename the class that was created automatically to 'SiteMapInfo.' We also want it to implement our IBaseEntity interface as well so we'll do that at the same time. (and we'll add namespace information too). Your module should start out looking like this (I'm not showing the stubs auto created by pressing enter after the implements statement):
Namespace BLL
Public Class SiteMapInfo
Implements IBaseEntity
End Class
End Namespace
- Now we can define the properties in the class. All the CanXYZ properties are pretty easy to define. All we do is add a single line to each (and they're all the same). Add the following line to the CanAdd, CanDelete, CanEdit and CanRead properties:
Return True
- Then we can define the IsXYZ properties. These are not the same as each other. Altogether they should read like this:
Dim bIsDirty As Boolean = False
Public Property IsDirty() As Boolean Implements IBaseEntity.IsDirty
Get
Return bIsDirty
End Get
Set(ByVal value As Boolean)
bIsDirty = value
End Set
End Property
Public ReadOnly Property IsValid() As Boolean Implements _
IBaseEntity.IsValid
Get
If String.IsNullOrEmpty(URL) = False And _
String.IsNullOrEmpty(RealURL) = False And _
String.IsNullOrEmpty(Title) = False Then
Return True
End If
Return False
End Get
End Property
Private _SetName As String = "SiteMap"
Public Property SetName() As String Implements IBaseEntity.SetName
Get
Return _SetName
End Get
Set(ByVal value As String)
_SetName = value
End Set
End Property
- Finally, we want to extend a partial Sub declared in our SiteMapInfo class (in the model file) (see page 99-100). This allows us to do a little validation. Add the following Sub to your class:
Private Sub OnSiteMapIdChanging(ByVal value As Integer)
If value < 0 Then
Throw New ArgumentException("The SiteMapId _
cannot be less than 0.")
End If
End Sub
Extending the SiteMapEntities Class (Originally Generated in the Model)
On page 86 we are told that we are going to add one event handler to our SiteMapEntities class, SavingChanges. To do this, we will again leverage partial classes much as we did with OnSiteMapIdChanging in our SiteMapInfo class above. To do this, do the following:
- Add a new class to your project, this should be added to the 'Navigation' folder and should be named 'SiteMapEntities.vb'.
- Add the following code to your SiteMapEntities.vb class file (from page 86)*Gap-Filled* (Sorry for the ugly formatting, some of the lines are rather long):
Namespace BLL
Public Class SiteMapEntities
Private Sub SiteMapentities_SavingChanges( _
ByVal sender As Object, ByVal e As System.EventArgs) _
Handles Me.SavingChanges
Dim typeEntries = (From entry In _
Me.ObjectStateManager.GetObjectStateEntries _
(EntityState.Added Or EntityState.Modified) _
Where TypeOf entry.Entity Is IBaseEntity).ToList()
For Each ose As System.Data.Objects.ObjectStateEntry _
In typeEntries
Dim lBaseEntity As IBaseEntity = _
DirectCast(ose.Entity, IBaseEntity)
If lBaseEntity.IsValid = False Then
Throw New BeerHouseDataException _
(String.Format("{0} is not valid", _
lBaseEntity.SetName), "", "")
End If
Next
End Sub
End Class
End Namespace
- This class is used to validate the entries that will be saved. It cycles through all the Added and Modified entries and then tests them for validity. If they fail, then an exception is thrown.
NOTE: A custom BeerHouseDataException is used here, I could not find any mention of it in Chapter 3, and it isn't listed in the index. So, I will provide it as found directly in the project files.
A BeerHouseDataException Class
I found the following code for the BeerHouseDataExeception Class. This class should be created in the root of the BLL project and is named BeerHouseDataException.vb. Define our class as follows:
Public Class BeerHouseDataException
Inherits Exception
Private _propertyName As String
Public Property PropertyName() As String
Get
Return _propertyName
End Get
Set(ByVal value As String)
_propertyName = value
End Set
End Property
Private _propertyValue As String
Public Property PropertyValue() As String
Get
Return _propertyValue
End Get
Set(ByVal value As String)
_propertyValue = value
End Set
End Property
Public Sub New(ByVal Message As String, _
ByVal Innerexception As Exception, _
ByVal PropName As String, ByVal PropValue As String)
MyBase.New(Message, Innerexception)
PropertyName = PropName
PropertyValue = PropValue
End Sub
Public Sub New(ByVal Message As String, ByVal PropName As String, _
ByVal PropValue As String)
Me.New(Message, Nothing, PropName, PropValue)
End Sub
Public Sub New(ByVal PropName As String, ByVal PropValue As String)
Me.New("A Property of an object was improperly set", Nothing, _
PropName, PropValue)
End Sub
End Class
Epilogue
Well, we've actually covered quite a bit of code. I found when I read the chapter a few times that more of the code was in there than I originally thought, but that there were some CRITICAL pieces that are just plain missing.
You'll also notice that there is still a lot of code missing. Since this blog posting is getting kind of long, I will continue filling gaps in this chapter in a Part 2. Some of the things that may still need addressed include: the SiteMap handler, a custom SiteMap provider and some URL rewrite code to name a few.
Prologue
In a previous post, I began dissecting the code in my ASP.Net 3.5 Website Programming: Problem - Design - Solution book attempting to fill in gaps that I found (such as where does the code in the book go and adding in code missing from the book). This is a continuation of that post, continuing in Chapter 3.
Problem
In this post, I will attempt to address the missing pieces regarding:
- The BasePage class.
- The SiteMap Provider.
- The SiteMap Handler.
- A URL Rewrite Class.
- An addition to our Helper Class.
- Configuring the ELMAH Error Logging Add-In.
So with no further ado, let's get started.
Solution
The BasePage Class has a fair amount of code presented in the book. Specifically I found the following code pieces in chapter 3:
- The MoveHiddenFields Property (pg 106)
- The MoveHiddenFieldsToBottom Function (pg 106)
- The OverRidden Render Subroutine (pg 107)
- The PrimaryKeyID Property (pg 107)
- The CreateMetaControl Subroutine (pg 138)
- The GetMetaValue Function (pg 139)
- The PageKeyWords Property (pg 139)
I did find, however, that we should also include a property to add content to the Description META tag. Adding this functionality is mentioned on page 121, however code is not included. It's very similar to your PageKeyWords property. This PageDescription property should be added to your BasePage class as follows *Gap-Filled*:
Protected Property PageDescription() As String
Get
Return GetMetaValue("DESCRIPTION")
End Get
Set(ByVal value As String)
CreateMetaControl("DESCRIPTION", value)
End Set
End Property
I also found, mentioned on page 122, a Robots META tag. I was unable to find any listing of this code in the book or in the downloaded files. So I created my own *Gap-Filled*. The book mentioned that the content section of the tag could contain combinations of action (index, noindex) and directive (follow, nofollow). I created an enumeration of the six valid values (including all and none) and called the CreateMetaControl with the corresponding string values. The code is as follows *Gap-Filled*:
Protected Enum RobotMetaOptions
IndexFollow
IndexNoFollow
NoIndexFollow
NoIndexNoFollow
All
None
End Enum
Protected Property PageRobotMeta() As RobotMetaOptions
Get
Return GetMetaValue("ROBOTS")
End Get
Set(ByVal value As RobotMetaOptions)
Select Case value
Case RobotMetaOptions.IndexFollow, RobotMetaOptions.All
CreateMetaControl("ROBOTS", "index, follow")
Case RobotMetaOptions.IndexNoFollow
CreateMetaControl("ROBOTS", "index, nofollow")
Case RobotMetaOptions.NoIndexFollow
CreateMetaControl("ROBOTS", "noindex, follow")
Case RobotMetaOptions.NoIndexNoFollow, RobotMetaOptions.None
CreateMetaControl("ROBOTS", "noindex, nofollow")
End Select
End Set
End Property
In looking around the web, it seems that really, the index-follow option (or all option) is redundant since that is the default action if there is no Robots META tag. I included the options just for the sake of completeness.
We can also notice that there is reference on page 121 to page title information. I found that again this is added in the code download, but I didn't find reference to it in the book. *Gap-Filled* I added the following property from the code download for PageTitle to my BasePage class:
Protected Property PageTitle() As String
Get
If Not IsNothing(Master) Then
If Master.Page.Title = String.Empty Then
Return String.Empty
Else
Return Master.Page.Title
End If
Else
If Page.Title = String.Empty Then
Return String.Empty
Else
Return Page.Title
End If
End If
End Get
Set(ByVal value As String)
If Not IsNothing(Master) Then
Master.Page.Title = value
Else
Page.Title = value
End If
End Set
End Property
There are a couple of other methods that I think may come in handy. Since I haven't read past this chapter yet, I'm not sure if they are added later as I can't find them in the index, but they are in the code download. I decided to add them just in case. I added the following to my BasePage class:
Public ReadOnly Property BaseUrl() As String
Get
Dim url As String = Me.Request.ApplicationPath
If url.EndsWith("/") Then
Return url
Else
Return url & "/"
End If
End Get
End Property
Public ReadOnly Property FullBaseUrl() As String
Get
Return Me.Request.Url.AbsoluteUri.Replace( _
Me.Request.Url.PathAndQuery, "") & Me.BaseUrl
End Get
End Property
There were also a couple that I decided NOT to add as I felt it was redundant to add them just to have them call the Helpers class. These include:
- SEOFriendlyURL
- ConvertToHTML
- GetUserProfile
- FormatPrice
Looking through the rest of the code, I'm thinking that some of that may be added later in the book or at least it may not apply till later so I will move on to the next class that needs work.
A Custom SiteMapProvider
On page 125, we're informed that we should be using a custom SiteMapProvider to generate our site map. Unfortunately, we aren't given ANY code that I find on how this is done with the exception of the settings to register the provider in the web.config file. We'll come back to that piece, but first let's create the provider so we know what to register. We're told that we can get a good primer on doing this from a series of 2006 MSDN articles. I found that some of the code comes across into our code download, and some doesn't. Also the article is in C#, so I relied heavily on the code download. Create a TBHSiteMapProvider.vb class in your 'navigation' folder *Gap-Filled* and add the following to get started:
Public Class TBHSiteMapProvider
Inherits StaticSiteMapProvider
Private Const _errmsg1 As String = "Missing node ID"
Private Const _errmsg2 As String = "Duplicate node ID"
Private Const _errmsg3 As String = "Missing parent ID"
Private Const _errmsg4 As String = "Invalid parent ID"
Private Const _errmsg5 As String = "Empty or missing connectionStringName"
Private Const _errmsg6 As String = "Missing connection string"
Private Const _errmsg7 As String = "Empty connection string"
Private Const _errmsg8 As String = "Invalid sqlCacheDependency"
Const _cacheDependancyName As String = "__SiteMapCacheDependency"
Private _connect As String
Private _database, _table As String
Private _2005dependency As Boolean = False
Private _indexID, _indexTitle, _indexUrl, _indexDesc, indexRoles, _indexParent As Integer
Private _nodes As New Dictionary(Of Integer, SiteMapNode)(16)
Private _root As SiteMapNode
Public ReadOnly _lock As Object = New Object()
End Class
This will get our class started, you may need to add import statements as the need arises, but this gets our class going. You'll notice that our class declaration gives us an error. We need to define a number of methods in this class before it will compile. So let's get started, create an Initialize method. The code IS different than on the MSDN article starting from about the half way point. Add the following to your class:
Public Overloads Overrides Sub Initialize(ByVal name As String, _
ByVal config As NameValueCollection)
'Verify parameters
If IsNothing(config) Then Throw New ArgumentNullException("config")
If String.IsNullOrEmpty(name) Then name = "TBHSiteMapProvider"
'add default desc to config if empty
If String.IsNullOrEmpty(config("description")) Then
config.Remove("description")
config.Add("description", "SQL site map provider")
End If
'call base's initialize
MyBase.Initialize(name, config)
If config("securityTrimmingEnabled") Is Nothing Then
config.Remove("securityTrimmingEnabled")
End If
If config.Count > 0 Then
Dim attr As String = config.GetKey(0)
If Not String.IsNullOrEmpty(attr) Then
Throw New ProviderException("Unrecognized attribute: " & attr)
End If
End If
End Sub
We also need to add one of the required methods BuildSiteMap. This method is substantially changed from the one in the MSDN article, so I pulled it from the downloaded code. However, this version used two other methods that haven't been created yet. We'll start with those methods and then come back to the BuildSiteMap. Add the following two methods to your class:
Private Function CreateSiteMapNodeFromSiteMapEntity(ByVal node As _
SiteMapInfo) As SiteMapNode
Dim roles As String = If(node.Roles, Nothing)
'if roles were specified, turn the list into a string array
Dim roleList As String() = Nothing
If Not String.IsNullOrEmpty(roles) Then
roleList = roles.Split(New Char() {","c, ";"c}, 512)
End If
'create SiteMapNode
Dim _node As New SiteMapNode(Me, node.SiteMapId.ToString(), _
node.URL, node.Title, node.Description, _
roleList, Nothing, Nothing, Nothing)
'record node in the _nodes directory
_nodes.Add(node.SiteMapId, _node)
Return _node
End Function
Private Sub AddChildNodes(ByVal vParentNode As SiteMapNode, _
ByVal SiteMapId As Integer)
Dim lChildNodes As List(Of SiteMapInfo) = (From lChildren In _
lSiteMapNodes Where lChildren.Parent = SiteMapId).ToList()
For Each lChildNode As SiteMapInfo In lChildNodes
Dim lNode As SiteMapNode = _
CreateSiteMapNodeFromSiteMapEntity(lChildNode)
AddNode(lNode, vParentNode)
AddChildNodes(lNode, lChildNode.SiteMapId)
Next
End Sub
You'll notice if you look at the MSDN article, that they aren't included there so they are newly created for our project from the code download. Now that they're defined, we're ready to add the BuildSiteMap method:
Protected lSiteMapNodes As List(Of SiteMapInfo)
Public Overrides Function BuildSiteMap() As SiteMapNode
SyncLock _lock
If _root IsNot Nothing Then Return _root
Using lSiteMapContext As New SiteMapRepository
lSiteMapNodes = lSiteMapContext.GetSiteMapNodes()
If lSiteMapNodes.Count > 0 Then
Dim node As SiteMapInfo = (From lSiteMapNode In _
lSiteMapNodes Where lSiteMapNode.Parent = 0).FirstOrDefault
If Not IsNothing(node) Then
_root = CreateSiteMapNodeFromSiteMapEntity(node)
AddNode(_root, Nothing)
AddChildNodes(_root, node.SiteMapId)
End If
End If
End Using
Return _root
End SyncLock
End Function
If you look at the errors, you'll see that we still have to define another overridden function, GetRootNodeCore. Again, we'll go to the code download since it isn't in the article or the book. Add the following to your class:
Protected Overloads Overrides Function GetRootNodeCore() As SiteMapNode
SyncLock _lock
BuildSiteMap()
Return _root
End SyncLock
End Function
It's a pretty simple function, but necessary. The provider also needs to be registered in the web.config file. To do this, add the following to your web.config file, it should go somewhere in your <system.web> element:
<siteMap defaultProvider="TBHSiteMapProvider" enabled="true">
<providers>
<add name="TBHSiteMapProvider"
type="TheBeerHouse.TBHSiteMapProvider"
securityTrimmingEnabled="true" />
</providers>
</siteMap>
There are two other methods in the downloaded code, however I'm not sure exactly what they do, I can't that they are called/referenced anywhere else. Since I don't see where they are used (at least at this point), I haven't included them.
A SiteMapHandler Class
This class is a handler that returns the sitemap.xml file used by search engine spiders so that our site can be easily indexed. This class DOES have some code included in the book, specifically it starts on page 126. Before we delve into the code there, let's create our file by adding a new class to the navigation folder and naming it 'SiteMapsHandler.vb' *Gap-Filled*. Following the code in the book starting at the bottom of page 126, we want to make sure that our class implements the iHttpHandler interface (requiring import of System.Web). When you press enter, you'll get stubs for two required elements (IsReusable property and ProcessRequest Method).
The IsReusable property can just return true *Gap-Filled*. ProcessRequest has a few more lines, but only a couple. You can find them at the top of page 127. As we start, your class should look like this:
Imports System.Web
Public Class SiteMapHandler
Implements IHttpHandler
Public ReadOnly Property IsReusable() As Boolean Implements _
System.Web.IHttpHandler.IsReusable
Get
Return True
End Get
End Property
Public Sub ProcessRequest(ByVal context As System.Web.HttpContext) _
Implements System.Web.IHttpHandler.ProcessRequest
BaseContext = context
CreateSiteMap()
Response.Flush()
Response.End()
End Sub
End Class
You'll notice that about every line gives us an error since nearly everything in this method is undefined. Examining the downloaded file, we find that these undefined items are actually properties created in our class (with the exception of CreateSiteMap()) *Gap-Filled*. Add the following properties to your class:
Dim _baseContext As HttpContext
Public Property BaseContext() As HttpContext
Get
Return _baseContext
End Get
Set(ByVal value As HttpContext)
_baseContext = value
End Set
End Property
Public ReadOnly Property Response() As HttpResponse
Get
Return BaseContext.Response
End Get
End Property
Public ReadOnly Property Request() As HttpRequest
Get
Return BaseContext.Request
End Get
End Property
This will take care of all but our CreateSiteMap() method error. So without further ado we'll add our CreateSiteMap method. This can be found on page 128. (Note: if you haven't used XDocument to do XML before, it is very particular about where you put your %> characters so follow the below code exactly so that you don't get errors - I learned by hard experience since I hadn't used it before). Add the following for your CreateSiteMap method:
Private Sub CreateSiteMap()
Response.ContentType = "application/xml"
Dim lSiteMapNodes As List(Of SiteMapInfo)
Using siteMaprpt As New SiteMapRepository
lSiteMapNodes = siteMaprpt.GetSiteMapNodes
End Using
Dim xSiteMap As XDocument = <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<%= From lSiteMapNode In lSiteMapNodes.AsEnumerable _
Select <url>
<loc><%= lSiteMapNode.URL %></loc>
<lastmod><%= lSiteMapNode.DateUpdated %></lastmod>
<changefreq>weekly</changefreq>
<priority>0</priority>
</url> %>
</urlset>
Response.Write(xSiteMap.ToString())
End Sub
Now we also need to add our handler to the web.config so that our website knows to use it. Add the following to the httpHandlers section of your web.config:
<add verb="GET" path="sitemap.xml" validate="false" type="TBH.SiteMapHandler, TBHBLL, Version=3.5.0.1, Culture=neutral, PublickeyToken=null" />
A URL Rewrite Class
This I feel is one of the less complete modules in the chapter. Originally when I attempted to create the code from this chapter I was only creating code that I found in the solution section. This meant that a couple critical pieces of this module were left out. By looking through the chapter, there was actually much more available.
First we'll need to create a class for our Rewrite code. This should go in a folder called 'Module' in our BLL. Once the folder is created, add a class file and name it URLRewrite.vb. *Gap-Filled*
We get our first code pieces starting on page 124 in the book. Right off, we should make sure that our class implements the IHttpModule interface, so add that to your class definition. Next, we can the code we need for the Init method. We can add that and you class should look something like this:
Public Class URLRewrite
Implements IHttpModule
Public Sub Init(ByVal context As System.Web.HttpApplication) _
Implements System.Web.IHttpModule.Init
AddHandler context.BeginRequest, AddressOf BeginRequest
End Sub
Public Sub Dispose() Implements System.Web.IHttpModule.Dispose
End Sub
End Class
You'll notice that while the book says we should intercept the AuthorizeRequest event that we're using the BeginRequest event. There is a difference between what we see on page 124 and what we read later on pages 135-6, but I went with the one that matched the code download, BeginRequest. *Gap-Filled*
Next, skip over to page 135 and we'll start adding the rest of the module. We will create a variable to use for Regular Expressions. Add the following to your class:
Private Shared wwwRegex As New Regex("https?://www\.", RegexOptions.IgnoreCase Or RegexOptions.Compiled)
Following along on page 136, we'll also add the BeginRequest event handler that we registered in our Init method. This is defined as follows:
Private Sub BeginRequest(ByVal sender As Object, ByVal e As EventArgs)
Dim app As HttpApplication = CType(sender, HttpApplication)
Dim Request As HttpRequest = app.Request
Dim Response As HttpResponse = app.Response
Dim sRequestedURL As String = Request.Url.ToString.ToLower
Dim redirectURL As String = String.Empty
Dim bWWW As Boolean = wwwRegex.IsMatch(sRequestedURL)
If bWWW Then redirectURL = wwwRegex.Replace(sRequestedURL, _
String.Format("{0}://", Request.Url.Scheme))
Rewrite(app)
End Sub
The Rewrite method is next, it is found on page 137 towards the bottom. Unfortunately there is a SIGNIFICANT difference between the code in the book and the code in the downloaded version. The book talks about doing a 301 redirect and looking up the information in the sitemap table, however the book doesn't seem to implement this. The downloaded code seems to to this, so I will be including that code rather than just the book's code. *Gap-Filled* To start, let's create a helper function for our Rewrite method. Add the following to your code:
Private Sub Do301Redirect(ByVal response As HttpResponse, _
ByVal redirectURL As String)
response.RedirectLocation = redirectURL
response.StatusCode = 301
response.End()
End Sub
Now let's create our Rewrite method. Add the following:
Private Sub Rewrite(ByVal app As HttpApplication)
If app.Context.Request.Path.ToLower.EndsWith(".aspx") Then
Using lSiteMapRst As New SiteMapRepository_
(Globals.Settings.DefaultConnectionStringName)
Dim lURLFile As String = _
Helpers.GetURLPath(app.Context.Request.Url.ToString)
Dim lSiteMap As SiteMapInfo = lSiteMapRst.GetSiteMapInfoByURL _
(lURLFile.Replace(Globals.Settings.devSiteName, ""))
If Not IsNothing(lSiteMap) Then
If lSiteMap.RealURL <> lURLFile Then
HttpContext.Current.RewritePath("/" & lSiteMap.RealURL, _
False)
ElseIf lSiteMap.URL <> lSiteMap.RealURL Then
Do301Redirect(app.Response, _
Path.Combine(Globals.Settings.SiteDomainName, _
lSiteMap.URL))
End If
End If
End Using
End If
End Sub
I did make one change from the downloaded code, I removed a settings variable since all we did was get our Globals.Settings object and it wasn't that much more work to add the Globals path to the beginning of the settings object when it was called.
You'll also notice that we created an error with our Helpers.GetURLPath that we haven't defined yet. We'll go define that now so that we can successfully build our application.
Another Method for our Helpers Class
I haven't found where much of the Helper class is defined in the book, so I went to the code download for this method. *Gap-Filled* Go over to your Helpers class and we'll add the following method:
Public Shared Function GetURLPath(ByVal sUrl As String) As String
Dim _Regex As Regex = New Regex("://[^/]+/(?<path>[^?\s<>#""]+)")
If _Regex.Matches(sUrl).Count > 0 Then
Return _Regex.Match(sUrl).Groups(1).ToString()
End If
Return sUrl
End Function
Configuring the ELMAH Error Logging Add-In
One of my little pet peeves is when you download something and then have to search all over the internet for specific instructions on how to install and configure it. This was the case for some information for getting ELMAH started in our project. I think I'm going to have to pull from a couple articles, as well as using the book, to get it all 100% happy. *Gap-Filled*
Starting on page 139, it talks about configuring ELMAH, so we can get some of our direction from the book, however before we do that, we'll need to get it installed. I found a short article that I based my installation on, basically you:
- Download ELMAH.
- Extract the Files.
- Copy the Elmah.dll into the BIN directory of your website.
- Configure ELMAH in your web.config.
We can now go to the book and do a little bit of configuration. First we need to add a Section definition to our web.config, much like we needed to do for our BeerHouseSection for configuration. Add the following to the ConfigSections element of your web.config:
<sectionGroup name="elmah">
<section name="security" requirePermission="false" type="Elmah.SecuritySectionHandler, Elmah" />
<section name="errorLog" requirePermission="false" type="Elmah.ErrorLogSectionHandler, Elmah" />
<section name="errorMail" requirePermission="false" type="Elmah.ErrorMailSectionHandler, Elmah" />
<section name="errorFilter" requirePermission="false" type="Elmah.ErrorFilterSectionHandler, Elmah" />
</sectionGroup>
Now we can add the configuration information into an Elmah section in our web.config. Add the following to the Configuration section in your web.config.
<elmah>
<security allowRemoteAccess="0" />
<errorLog type="Elmah.SqlErrorLog, Elmah" connectionStringName="" />
<errorFilter>
<test>
<equal binding="HttpStatusCode" value="404" valueType="Int32" />
</test>
</errorFilter>
<errorMail from="error@thebeerhouse.com" to=dogbert@dogbert.com subject="Exception in the Beerhouse Site." async="true" smptPort="25" smtpServer="mail.thebeerhouse.com" />
</elmah>
You'll notice that I have made a correction in the 'equal' element. The book has incorrectly listed this attribute as valueType *Gap-Filled*, the correct attribute is type. Finally for configuration we need to add handlers so that the modules will actually be used. We need to add the following to our system.web section within the HttpModules subsection:
<add name="ErrorLog" type="Elmah.ErrorLogModule, Elmah" />
<add name="ErrorMail" type="Elmah.ErrorMailModule, Elmah" />
<add name="ErrorFilter" type="Elmah.ErrorFilterModule, Elmah" />
and the following into our system.web's httpHandlers section:
<add verb="POST,GET,HEAD" path="elmah.axd" type="Elmah.ErrorLogPageFactory, Elmah" />
Finally, one last piece that I had a harder time finding, and that is setting up the database. To do this, you need to add the ELMAH table and it's 3 stored procedures (thanks NinethSense). If you downloaded the ELMAH packages, there is a folder in the zip file labeled 'DB'. In this folder, you will find the script to setup your DB. I chose to use SQL server so I loaded the SQLServer.sql file into my SQL Server Management Studio and ran the script. Viola the DB is setup.
Epilogue
Well, there you go, hopefully you'll find this helpful, I certainly would have.
Having finished with this particular chapter, I have started reading further (albeit in pieces here and there) in chapter 5 and I think that actually everything will be much easier to understand in these chapters. If they aren't, then look forward to another post in the series...?
Prologue
Most of the development I do centers around ASP.Net rather than WinForms. I do however do a little work in the WinForms sphere occasionally. I was putting something together the other day and found that there are some little, yet significant differences between the WinForms ComboBox and the ASP.Net DropDownList. Specifically I found that in the ASP.Net DropDownList, you can set the displayed text to be different than the value. The WinForms ComboBox doesn't allow this.
Problem
In the application I was developing, I wanted the ComboBox to behave like the ASP DropDownList and allowing me to display text yet have the VALUE returned be an integer that corresponded with another array. WinForms DOES support this, HOWEVER, not in as straight forward a way as you might think. (In the interest of giving credit where credit is due, I pieced together much of this post by reading a post and subsequent comments on Martin G. Brown's blog - Thanks for the help Martin).
Solution
First, a little about the difference between ASP and WinForms ComboBoxes. Take the example here of an ASP.Net DropDownList's front-end code:
<asp:DropDownList runat="server" ID="ddlTest">
<asp:ListItem Text="My Name" Value="0" />
<asp:ListItem Text="Your Name" Value="1" />
</asp:DropDownList>
or the following back-end code for adding items to an existing DropDownList:
ddlTest.Items.Add(New ListItem("My Name", 0))
ddlTest.Items.Add(New ListItem("Your Name", 1))
In either case, notice that I can explicitly set the value and the text to be different. This allows me to display friendly text, yet pass around a key (such as an ID) when the user selects the entry rather than having to use the text as a key. Also notice that I can set both of these at the same time. This would allow me to pull a list of objects, say names from a database, and then display them along with associated values (such as a primary key id) pretty quickly. Let's look at doing this with the WinForms combo box.
Let's do a little setup here so we can continue with our project when we're done "looking." Create a Windows Form application and add a ComboBox (name it cmboDoIt) and two Buttons (one named btnDoIt and one named btnShowIt).
Click on the ComboBox, go to the properties pane and click the ... button next to Items, you'll notice that the String Collection Editor comes up. It asks you to enter the strings for the collection, one per line. So you can enter the text, but not the values. Let's try doing it runtime and see if that'll do it.
If you go to the code behind and try adding an item, like you did for the DropDownList. Notice that the Item that you add is of type Object. But of what kind of object? String. This doesn't allow you to add a value to the item. So how could we do this? The answer lies in the ability of a ComboBox to be bound to a data source. If we create a data source with more than one "column", we can bind to the data source and then SPECIFY which column will be the text and which will be the value.
To do this, we first need to create a data source. We'll do this by creating a class to hold our two data pieces. Add the following class definition to our form, just before the final End Class tag:
Public Class ValueDescriptionPair
Private _value As Object
Private _description As String
Public Sub New(ByVal Description As String, ByVal Value As Object)
_value = Value
_description = Description
End Sub
Public ReadOnly Property Value() As Object
Get
Return _value
End Get
End Property
Public ReadOnly Property Description() As String
Get
Return _description
End Get
End Property
Public Overrides Function ToString() As String
Return _description
End Function
End Class
Basically we create a class that can hold both our value and our description. We add a ToString function so that we don't end up with any funky output when we use our description. We also define a constructor so we can pass our value/description pair in to create the object. Now we can create an array of our ValueDescriptionPair objects, load it with data (we'll just do it by hand in this example) and then bind it all to our ComboBox. To do this, add the following to our btnDoIt_Click event handler.
Dim vdpArray As New ArrayList
vdpArray.Add(New ValueDescriptionPair("My Name", 0))
vdpArray.Add(New ValueDescriptionPair("Your Name", 1))
cmboDoIt.DataSource = vdpArray
cmboDoIt.ValueMember = "Value"
cmboDoIt.DisplayMember = "Description"
Here we create an ArrayList of our ValueDescriptionPair. We then add 2 elements, bind our DataSource and then define which member to assign as a value and which to assign as a description. Let's wire up one last piece before we run the application. Add the following to your btnShowIt_Click event handler:
MessageBox.Show("The value for " & cmboDoIt.SelectedItem.ToString & _
" is " & cmboDoIt.SelectedValue)
This will just display your selected entry and the corresponding value. Now to use the value, all you need to do is use the ComboBox's SelectedValue property and you're good to go.
Note: as an FYI, this can also be done using a generic collection. Simply change the following lines (remember to add the Imports System.Collections.Generic statement to the top of the code as well):
Dim vdpArray As New ArrayList
Dim vdpArray As New List(Of ValueDescriptionPair)
Epilogue
There you have it. Pretty simple once you've got it all in place, but very convenient when needed. I'm not sure why you can't add data items that contain both value and description like you can with the DropDownList but it's an easy work around.
Prologue
On our company website, the one I mention so frequently (or is it infrequently as often as I post…), we have a data display prominently mounted where it can be viewed by people who are not employees. If they visit our facility, they can view the data (in a GridView) that shows basic information they want to know. The information that feeds the dataset has quite a bit more information that may actually be helpful to various departments within the company, but we don't want to display it to non-employees. What I’d like to do is retain the same GridView but reveal more data when someone in the company clicks the row. Basically, I want to have every other row hidden and then reveal an associated, hidden row when the non hidden row is clicked. (FYI - I also have code I don't talk about in this post that disables this functionality for people who are not inside the company).
Problem
A possible solution that I found was the CollapsiblePanel control that is part of the AJAX Control Toolkit. This provides functionality to do pretty much what I was looking to do. The question is trying to integrate it with a GridView. In my searching on the Internet, I didn’t find much that would suffice for what I wanted to do. One of the closest I found was a kind of an integrated master/detail that I found on a blog. I chose to go ahead and try to use the CollapsiblePanel control. The problem that I found is that you can’t alternate data in a GridView, the row is the row and nothing else. In other words, I found I couldn’t do the 2 things I wanted to with the row 1. hide it (every other row should be hidden), and 2. put different data in it automatically (i.e. different data in every other row, one with public info, one with private). I did get it working, but it's not a wonderfully clean solution, not quite like I expected. Let’s examine how I did it.
Solution
To get started we need to create a new page and add to it a GridView control, it also needs a little configuration so, add the following markup code to your front-end code:
<cc1:ToolkitScriptManager ID="ToolkitScriptManager1" runat="server">
</cc1:ToolkitScriptManager>
<asp:gridview ID="gvwTest" runat="server" AutoGenerateColumns="true"
AllowSorting="True">
</asp:gridview>
We've just created a very basic GridView, nothing much to look at. We also added a ScriptManager for our CollapsPanelExtender to use later. Next we'll create a data source to populate the GridView. Add the following to your Page_Load event:
gvwTest.DataSource = CreateDataSource()
gvwTest.DataBind()
And then we'll add a helper function CreateDataSource to our application so that it will create and populate the data source, add the following function:
Public Function CreateDataSource() As DataTable
Dim dt As New DataTable()
dt.Columns.Add(New DataColumn("FirstName", GetType(String)))
dt.Columns.Add(New DataColumn("LastName", GetType(String)))
dt.Columns.Add(New DataColumn("City", GetType(String)))
dt.Columns.Add(New DataColumn("State", GetType(String)))
Dim dr As DataRow
dr = dt.NewRow()
dr("FirstName") = "Sally"
dr("LastName") = "Smith"
dr("City") = "Phoenix"
dr("State") = "Arizona"
dt.Rows.Add(dr)
dr = dt.NewRow()
dr("FirstName") = "John"
dr("LastName") = "Doe"
dr("City") = "Seattle"
dr("State") = "Washington"
dt.Rows.Add(dr)
Return dt
End Function
This function creates a DataTable and then adds 4 columns, 2 for the name and 2 for their location. Next, we create 2 rows and populate the data. Finally, we return the DataTable.
Now we're ready to get started. If you run your project, you'll see that we have 2 rows each with 4 columns. Let's say though that you'd rather have the location be on the next row and have it revealed only when you click on the name. How do we get 1/2 of one row to display as the 1st row and the 2nd half of the SAME row to display as the 2nd row? The answer is that you can't. If we want to create something custom like that, we need to create a custom TemplateField and this will have to be laid out by hand, I chose to do it by table.
So let's customize our GridView. We'll add support for the collapse panel at the same time. Let's start with how we want to lay it out. We want one row with the full name. We don't want the location to show until the name is clicked, and then we want it to "fly-out" (or down) and reveal the location. What we'll end up with is a table with two rows, one row with name one with location. This table will be nested in EACH row of our GridView. Let's get started by creating a header for our GridView. Add the following between our GridView tags:
<Columns>
<asp:TemplateField>
<HeaderTemplate>
</HeaderTemplate>
</asp:TemplateField>
</Columns>
We'll be using one actual GridView field, typically each field (in our data) would map to a cell in the row, but we're going to use one cell per GridView row to display our data. Within our HeaderTemplate we'll add a table labeling the data that will be displayed. Add the following inside your HeaderTemplate:
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell>First Name</asp:TableCell>
<asp:TableCell>Last Name</asp:TableCell>
</asp:TableRow>
</asp:Table>
That sets the header for the GridView, now for our row. We need to add two tables, one for each "row" we want to present (one for name, one for location). We'll also surround our tables each with a panel so we can target them with the CollapsePanelExtender. All this will be added to our ItemTemplate, add the following just below our HeaderTemplate:
<ItemTemplate>
<asp:Panel ID="pnlTitle" runat="server">
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell>
<asp:Image ID="imgToggle" ImageUrl="expand.jpg" runat="server" />
</asp:TableCell>
<asp:TableCell>
<asp:label ID="lblFirstName" Text='<%#Eval("FirstName")%>' runat="server" />
</asp:TableCell>
<asp:TableCell>
<asp:Label ID="lblLastName" Text='<%#Eval("LastName")%>' runat="server" />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
</asp:Panel>
<asp:Panel ID="pnlLocation" runat="server">
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell>
<asp:Label ID="lblCity" Text='<%#Eval("City")%>' runat="server" />
</asp:TableCell>
<asp:TableCell>
<asp:Label ID="lblState" Text='<%#Eval("State")%>' runat="server" />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
</asp:Panel>
</ItemTemplate>
You'll notice that I reference an image called 'expand.jpg' this is a little "expander" arrow. The CollapsePanelExtender will actually toggle this image with a 'collapse.jpg' that we'll define later. You can grab any expander and collapser image you like and just direct our code to it (I'm using the ones from the toolkit's sample project). If you run your project, you'll see that our first column has our tables in it and our other columns still appear. We can turn those off by changing our AutoGenerateColumns to false. If you run it again, we should have our tables and no extraneous columns.
Now that we've got everything in place, we just need to get our CollapsPanelExtender working.
We'll need a CollapsePanelExtender for each row that we'll be targeting so we should add the extender inside our ItemTemplate and put it directly after our last panel tag. We'll start with just the basics and add a couple things in a minute. Add the following just below your last panel tag:
<cc1:CollapsiblePanelExtender ID="cpeDetails"
runat="server"
TargetControlID="pnlLocation"
CollapseControlID="pnlTitle"
ExpandControlID="pnlTitle"
Enabled="true" >
</cc1:CollapsiblePanelExtender>
Now if you run your project, we'll have our two "rows" and if you click on the name row, the location row will shrink and if you click it again, it will reveal. Lets modify a couple settings to make it a little better. First let's add functionality for the expander image so it changes automatically, add the following attributes to your cpeDetails extender:
ImageControlID="imgToggle"
CollapsedImage="Collapse.jpg"
ExpandedImage="expand.jpg"
Collapsed="true"
This will set the CollapsPanelExtender to do image swapping and make will make the location panels collapsed by default. One other change to make would to alternate colors for the rows, add the following attribute to your GridView control:
AlternatingRowStyle-BackColor="Silver"
Run it again, and see that it works, we have one "row" with the name and another "row" with the location that can be revealed by clicking on the name.Notice that each row can be expanded and contracted separately from the other rows and that everything is in working order with the collapse and expand images. There we go, a working 'Collapsible' table from a GridView.
Epilogue
You'll notice that I said earlier, this isn't a really clean way to do things, I'll stand by that. The header doesn't necessarily match up with our data, and we're limited on what we can do about that. You can set some cell widths and cell alignments in our tables so that they move where they should be, but you only have so much control over a table, it might expand to fit contents and stuff like that.
However, it is a cheap and easy way to display some extra information pretty easily, and it does look reasonably good in our production environment.
Prologue
A funny thing happened on the way to the… management page. I found a kind of an anomaly taking place when you configure your Membership Provider use one application and your Role Provider is configured to use a different one. What I found is that I had users I deleted still assigned to some of my roles (phantom users).
Problem
In the process of creating ‘A Better ASP.Net User/Role Management Page’ found that I (without realizing it) had set the application name for my Roles Provider to be different than the one for the Membership Provider. This leads to some interesting side effects that I didn’t expect. Since I haven’t found any posts regarding this on the internet, I thought I’d touch on the subject mostly as an interesting side note so people can see what’s going on, not that we solve any problems per se but we’ll be informed at least by the time we’re done.
Solution
Before we can get started, we’ll need to make sure we have a website setup with a Membership and a Role provider configured. We should be able to piggyback off the SQLExpress Membership Provider website that was created in the last post. We’ll want to follow the post and create a Membership provider that we can configure. We’ll scrap all the front end and back-end code from the project except the web.config. We’ll also need to define a Role provider while we’re at it so after you add your Membership provider information, add the following as a Role provider right below it:
<roleManager enabled="true" defaultProvider="myCustomRoleProvider">
<providers>
<add name="myApp"
connectionStringName="mySqlServer"
type="System.Web.Security.SqlRoleProvider, System.Web,
Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b03f5f7f11d50a3a"/>
</providers>
</roleManager>
As a side note, make both your membership and roles provider’s applicationName=”myApp”, so that we start with our providers using the same application. Along the same lines of the project we’re piggybacking on, this will allow us to customize our role provider using the ASPNETDB database that was provided to us. Next we’ll want to create a front end that will allow us to show the results we’re creating as we go, so we know what’s happening.
Open a blank default.aspx (or create it if it doesn’t already exist) and add the following to the front-end code:
<div>
Roles by User:<br />
<asp:Label ID="lblRolesByUser" runat="server" Text="" />
<br />
<br />
Users by Role:<br />
<asp:Label ID="lblUsersByRole" runat="server" Text="Label" />
</div>
This give us two labels to populate, one we’ll populate with our users and show what roles they are assigned to, and the other we’ll populate with the roles and the users that are assigned to them. Now for the back-end code. Add the following helper functions to the code behind:
Private Function GetRolesByUser() As String
Dim sb As New StringBuilder()
For Each member As MembershipUser In Membership.GetAllUsers()
sb.Append("<strong>" & member.UserName & "</strong><br>")
For Each role As String In Roles.GetRolesForUser(member.UserName)
sb.Append("-" & role & "<br>")
Next
Next
Return sb.ToString()
End Function
Private Function GetUsersByRole() As String
Dim sb As New StringBuilder()
For Each role As String In Roles.GetAllRoles()
sb.Append("<strong>" & role & "</strong><br>")
For Each member As String In Roles.GetUsersInRole(role)
sb.Append("-" & member & "<br>")
Next
Next
Return sb.ToString()
End Function
These functions do exactly what we were talking about before. Once cycles through the members in the database, retrieves the roles they are assigned to and then displays this list. The other cycles through the roles and makes a list of each role and all the members assigned to it then displays it. To run the functions, add the following to your Page_Load event handler:
lblRolesByUser.Text = GetRolesByUser()
lblUsersByRole.Text = GetUsersByRole()
This will populate the labels we have on the page using our two helper functions as soon as the page is run. Now were’ ready to do some playing around. Let’s make sure that to start, you have your membership and roles providers both using the same application name. This way we can see how things SHOULD behave. Add the following line to both your membership and your role provider:
applicationName="myApp"
For First My First Trick – or – Meet the Phantom User
Now let’s go add some users and roles and make some assignments. If you open the solution explorer panel and click ‘ASP.Net Configuration’ button, we’ll get the ‘ASP.net Web Site Administration Tool’ (WSAT – well call it) webpage. Let’s add 2 users and 2 roles and then assign 1 user to each role. For my purposes, I’ll use:
| Users | Roles |
| MajorPain | BigManOnCampus |
| JunkMeister | LittleLoser |
and just assign roles directly across a row. Once we have our users and roles setup, we can run our application and we should get a listing that looks like this:
Roles by User:
JunkMeister
-LittleLoser
MajorPain
-BigManOnCampus
Users by Role:
BigManOnCampus
-MajorPain
LittleLoser
-JunkMeister
You can see that we have our 2 users, 2 roles and 2 assignments (each user is assigned to one role). All this is displayed to us two different ways. Now delete one of your users (I got rid of MajorPain) and refresh the page. Your results should look something like this:
Roles by User:
JunkMeister
-LittleLoser
Users by Role:
BigManOnCampus
LittleLoser
-JunkMeister
Notice that we have our 1 user, and that the user we deleted was removed from the roles list as well, we have 1 user, 2 roles, and only 1 user assigned. This is what we would expect to see. If we remove a user, the provider code should remove its entry from all the roles assignments.
Now let’s get a little fancy with things and see what happens. First, let’s change the name of the membership provider. In your Web.config, change the membership provider’s applicationName to be something different:
applicationName="myOtherApp"
Now refresh your page and see what gets listed. It should look something like this:
Roles by User:
Users by Role:
BigManOnCampus
LittleLoser
-JunkMeister
We have 0 users and 2 roles, but we do have 1 user assigned to our role… Wait a minute, how can we have a user assigned to our LitteLoser role if we don’t have any users? We’ll answer that in a minute. For now, let’s look at it the other way around, change our membership’s applicationName back to “myApp” and change the Roles provider’s applicationName to “MyOtherApp”. Refresh your page and see that it should come up like this":
Roles by User:
JunkMeister
Users by Role:
Notice that we have our user back, but we don’t have any roles in our application AND that since there aren’t any roles in our application, our user isn’t assigned to any… It seems there a bit of a disconnect? We get roles with a phantom user assigned, but not any phantom roles assigned for our user. Interesting, No?
A Good Magician Never Reveals – or – I am Not a Good Magician
Ok, let’s dig into what’s happening and see how it all works, change your membership and role’s applicationName back to myApp and setup with your 2 users, 2 roles and 2 assignments again. If you’ve got it setup right, your page should display as follows:
Roles by User:
JunkMeister
-LittleLoser
MajorPain
-BigManOnCampus
Users by Role:
BigManOnCampus
-MajorPain
LittleLoser
-JunkMeister
Now, let’s dig into the behind the scenes. Open your ‘Server Explorer’ panel. Open your database, then the tables node and let’s look into our tables. First, right-click your Users table and select ‘Show table data’. You should see 2 users, like the picture to the right. 
Let’s also dig into the Roles table, show the table data for the roles, and you should see a table with 2 roles, again nothing surprising (see image at right).
Ok, let’s look at one more table, show
the table data for your UsersInRoles table. You should see two assignments (two rows). While the IDs may look intimidating, we can see from our pictures above that the UserID comes from the Users table and the RoleID comes form the Roles table. Thus we can translate our IDs and see that we have our 2 users, 2 roles and 2 assignments.
Now, let’s repeat our previous experiment, but let’s start with the Role provider. Change your Role Provider’s applicationName and refresh our browser, we should get:
Roles by User:
JunkMeister
MajorPain
Users by Role:
2 users, 0 Roles and 0 role assignments, as it should be. Where did our roles go? For that we need to open one more table, show table data on our Applications table. This should show our ApplicationID’s notice how we have two applications, now notice that our roles and our users both have an applicationID as well. Both These all have an applicationID that matches our ‘myApp’ application. Meaning that when we change our Roles provider to to be ‘myOtherApp’, it no longer detects that we have any roles or role assignments since it looks for roles and role assignments only with our applicationID (Note: not strictly correct as we’ll see shortly). This allows you to use the same database for more than one website, each website just needs a different applicationID and the data resides in the same database.
Now let’s try it with our Membership Provider, change the Roles Provider’s applicationName back to ‘myApp’ and our Membership Provider’s applicationName to ‘myOtherApp’. Now refresh our page and see what happens. If it works like when we changed our Roles provider, we we would expect to end up with 2 Roles, 0 users, and 0 role assignments, as all the users assigned to roles exist in ‘myApp’ and we’ve ask for users in ‘myOtherApp’. Refresh your page and you should get:
Roles by User:
Users by Role:
BigManOnCampus
-MajorPain
LittleLoser
-JunkMeister
We should be expecting 0 users, 2 roles and 0 assignments, but Notice that we’re getting usernames that we SHOUDN’T be getting: 0 users, 2 roles and 2 assignments. What gives? Check out our code behind. In our ‘GetUsersByRole’ function, we’re getting all our roles, iterating through them and using the role to pull all members in the role by using the Roles.GetUsersInRole(role) method. For some reason, this built-in function has some kind of glitch where it doesn’t filter out users that aren’t in our specified application (myOtherApp). That’s the secret of the trick: we should be getting only users returned to us who are IN our specified application (myOtherApp), but in fact we’re getting back users that WE SHOULD NOT BE ABLE TO SEE by virtue of our configuration.
We can see part of the root of the problem by looking at our UsersInRoles table. Notice something missing? Yeah, it’s got no applicationID. When we use the Roles.GetUsersInRole, it should process the UserID against the applicationID (from a different table) and do a filter. It doesn’t.
To confirm that we’re getting roles assignments (and thus usernames) from multiple applications, right now add another user (MickyMouse) and assign him to the ‘BigManOnCampus’ role and refresh your page. You should see something like this:
Roles by User:
MickyMouse
-BigManOnCampus
Users by Role:
BigManOnCampus
-MajorPain
-MickyMouse
LittleLoser
-JunkMeister
We only have 1 user in our users list, but we have a total of 3 users to roles assignments AND we’ve pulled usernames from TWO DIFFERENT APPLICATIONS AT THE SAME TIME and used them!
I Think You Underestimate My Trickiness – or – Different appNames From the Get Go
Ok, now let’s get a little more hairy with it. While we’re here, delete your MickeyMouse user. and then refresh your page. Since we’re in the application where we created our MickyMouse user, the user should be deleted and removed from all roles, right? Refresh and see, you should see something like this:
Roles by User:
Users by Role:
BigManOnCampus
-MajorPain
-MickyMouse
LittleLoser
-JunkMeister
We’ve got 0 users, but 3 users assigned to roles. Technically, we’re pulling two of the users from a different application, so they DO exist in the database, but our MickeyMouse user no longer exists in the Database or he would show up in the user section (he’s not being filtered out, we’re in his application). What gives?
We’re finally getting down to the crux of the problem I originally found. I had accidentally created an applicationID for each the Role and the Membership, and didn’t even notice till I pulled the data we are here that little things were being left floating around (like our MickeyMouse assignment).
Let’s see if we can create the issue from the ground up and watch what’s happening in our database so we can see how it happens. Rather than going through the interface of the WSAT, I’ll have you delete directly from the tables since we won’t be able to 100% flush our users out otherwise (since MickyMouse doesn’t exist yet still shows up).
If you select all your rows in VS.Net, while ‘showing table data’, you can press delete and delete all the rows. Let’s do this for the following tables and in this order (no cheating, no looking at the data in the tables, now):
UsersInRole
Roles
Membership
Users
The order of tables here will give a little bit of hint of things to come, (hint: we haven’t mentioned the membership table at all, have we). Also, in preparation for our next excercise we want to make sure that our Membership and Roles providers have DIFFERENT applicationNames. Ok, now if you run your page again, you should end up with a blank list:
Roles by User:
Users by Role:
This is to be expected since we don’t have any users or roles in our database. So, now that we have everything setup, let’s add 2 users and 2 roles, but NOT make any assignments just yet. Open your WSAT and add back in our MajorPain and JunkMeister users and add back in our BigManOnCampus and LittleLoser roles. Refresh your page and you should see:
Roles by User:
JunkMeister
MajorPain
Users by Role:
BigManOnCampus
LittleLoser
2 users, 2 roles and 0 assignments – as expected. First, let’s look into our tables. Let’s start with UsersInRoles. Notice we get nothing (no assignments yet). Now, let’s check our Roles and Users tables. You shouldn’t be surprised to see that each has 2 entries, and also shouldn’t be surprised that the roles have a different ApplicationID than the users do.
Ok, now let’s add a users to a role. Start with MajorPain, add him to the BMOC role and then refresh our page. We should see our assignment:
Roles by User:
JunkMeister
MajorPain
-BigManOnCampus
Users by Role:
BigManOnCampus
-MajorPain
LittleLoser
Now let’s look back at our UsersInRole table and see what it looks like. Much like we’d expect, one row showing our one assignment. Now, let’s go back and look at our users table.
WHOA. Where did the extra MajorPain come from? Well, since we didn’t have a MajorPain in our Role’s Application, the database creates one in our users table and assigns it the ApplicationID for our Roles provider. Notice that we have two MajorPains, but they do have 2 different ApplicationIDs assigned to them (one for our membership provider’s application and one for our role provider’s application).
Now, observe what happens when we delete our user MajorPain. Go ahead and delete him. What happened? Right, only one disappeared. Notice it was the one associated with our Membership provider. Our Roles provider’s MajorPain user is still intact. Refresh our page and see what happens.
Roles by User:
JunkMeister
Users by Role:
BigManOnCampus
-MajorPain
LittleLoser
We have no MajorPain user, but we have a phantom MajorPain in our role assignments list. Thus we created a 2nd MajorPain user and now we’ve stranded him in our database. And we can’t just change our membership provider to match his applicationID and delete him. Change your Membership provider’s ApplicationName to match your Roles Provider and refresh your page. Theoretically, he should show up as a user right? WRONG! When you refresh, you get:
Roles by User:
Users by Role:
BigManOnCampus
-MajorPain
LittleLoser
No users, but we do get our phantom user to role assignment. What gives? Go back to our Membership table and take a look. We only have one row in this table, it belongs to JunkMeister (match is UserID up with the User’s table). But if you go to the User’s table and look you’ll see our phantom MajorPain user. Try to lookup our phantom MajorPain’s UserID, it isn’t in the Membership table. Unless the user has an entry in this table (Membership), it won’t show up when we use our membership methods. Even removing our ‘phantom’ user from the role doesn’t remove our phantom user from the Users table.
Epilogue
So there it is, phantom users in our membership/roles database.
Funny, I started out this post with the intent to create an innocently intentioned post about something strange that I found. However as I worked my way through presenting it, I found I was kind of disillusioned with the innocence. While I’m generally pretty happy with Microsoft and their related .Net technologies, it seems that this would be considered a fairly LARGE security loophole and/or could cause problems for any one of a number of reasons:
- By changing the role provider’s application name, I can (if they exist) retrieve usernames that belong to a different application. While it might be assumed that I wouldn’t be able to access more than one application without proper permission, this seems somewhat lacking in security. (Especially when you consider some of the functionality that wasn’t baked into the providers for security’s sake – such as changing a username – it seems that this wasn’t a deliberate oversight).
- Along with the above problem, I can, by changing the role provider’s application name, pull username/role assignments from MULTIPLE applications AT ONE TIME. Again, this seems like somewhat of a security problem – and I’ll get INCORRECT data that may give me hiccups in my code.
- I can retrieve usernames and role associations that no longer exist. This poses a problem if I want to process things using information from the roles list. I may not be able to manipulate things based on the Roles.GetUsersInRoles() method, because it returns INCORRECT data and may thus return some unexpected exceptions.
- We can create phantom users in our users table, however, we CANNOT, through the providers mechanisms provided by Microsoft, purge the phantom user(s) entirely from the database.
- And on top of it all, I wasn’t able to find ANY documentation anywhere regarding the issue. Maybe nobody else is dumb enough to split their providers (like I was), but it seems like if it was designed to behave this way, that it would be explained somewhere in documentation or at least on a blog somewhere. At least tell us it’s possible, but warn us that it’s
stupid a bad idea.
Anyhow, as I see it “Lucy, you got some splainin’ to do!” – or – At the very least, we should be very careful to make sure that we don’t mix up our applicatioNames when we are configuring our providers, or we may get some unexpected and undesirable results.
Prologue
In the process of writing the last series of posts to create ‘A Better ASP.Net Member/Role Management Page’ (here’s a link to the 1st part), I used a SQL Express database located in my App_data folder. This was the first time I’ve used one. I have access to a full blown external SQL Server for development (since that’s what I’ll be doing in production). Due to my lack of experience with this form of database, I had a heck of time figuring out how to configure my membership provider. In part 4 of the series, I looked at enabling/disabling some functionality of the page based on how the Membership provider was configured. Obviously, to check that the functionality of the page is working correctly, I needed to change the configuration of the provider. Therein lied the problem.
Problem
When I created my database in the App_Data folder for this project, VS.Net 2008 did a lot of the work for me. While this isn’t bad, what I found is that I had a dickens of time figuring out how to get my Membership provider configured to use something besides the default settings. So I thought I’d create an entry on how to do it since I had a tough time with it.
We’ll start from the very beginning (i.e. from scratch) for this post, but the basic problem is that when VS.Net does all the work, the web.config does NOT contain any membership provider configuration information, so it isn’t readily apparent how to change the provider’s configuration. Once we figure out what’s going on and how to rectify it, it actually becomes pretty easy though. (Note, giving credit where credit is due, I got a lot of information from this site, but have pared it down to the very basics).
Solution
First off, let’s start with a new website. We only need one page in the site, it comes with a default.aspx and we’ll just use that. We’ll begin by setting up the page to display a couple things. Add the following to your front-end code:
<div>
Configuration: <br />
<asp:TextBox ID="txtCurrentConfig" runat="server"
TextMode="MultiLine" Width="400" Height="200" />
<br /><br />
Members:<br />
<asp:GridView ID="gvMembers" runat="server">
</asp:GridView>
</div>
We’re creating a TextBox to hold some configuration information that we’ll retrieve in our back-end, and then a GridView that we’ll use to pull members from the database. This will help us to see if we’ve done something horribly wrong ;-).
We’ll need also to create our database before we can pull any membership from it. Right-click your App_Data folder and select ‘Add New Item…’ From this list, you want to select SQL Server Database. Name it ‘aspnetdb.mdf’ and click ‘Add’. Now we should have a database file in our App_Data folder. If you go to the Server Explorer panel, you can open the database, you’ll notice that it doesn’t have ANY tables in it, including the membership and roles tables that we want to be able to access. We need to add these.
Adding the membership and roles information basically amounts to running the aspnet_regsql.exe against our database and the the functionality will be added to the database. The article I referenced above has a great walkthrough on this, however I found that if I ran the aspnet_regsql tool in GUI mode, somehow the SQL service on my machine didn’t have rights to access the DB file (as it was in my My Documents folder under the VS 2008 folder, and somehow the GUI – despite being run under my account – couldn’t see my profile). Also, I couldn’t get my SQL Server to attach to the file for the same reason. What I did find that worked though, was an article that detailed how to run aspnet_regsql by command line (hey, and the article’s got a classy color scheme too ;-) ). This is how I’ll do our database. Open a command prompt and run the following:
aspnet_regsql -A all -C "Data Source=.\SQLEXPRESS;Integrated Security=True;User Instance=True" -d "C:\<Filepath>\APP_DATA\aspnetdb.mdf"
You should notice that if you have the filepath to your database file correct, it will add a few a few different things to your database and then report it is finished. If you refresh your database in the Server Explorer, you’ll see that now we have some tables (and stored procedures). We’re ready to do some stuff with our membership provider now.
Next, we’ll add a user to our database so that we can do some testing using our GridView. We’ll do it the easy way, open the Website Administration Tool by clicking the button on the toolbar in your Solution explorer. Once the page comes up, Click the ‘Security’ link. Click the link to ‘Select Authentication Type’, and we’ll set it to forms authentication. Select ‘From the internet’ and click ‘Done’. Also, we’ll click the link to ‘Enable Roles’. Now, let’s add a user to our database, click the ‘Create User’ link. On the next screen, let’s create a user, just to prove a point, use ‘password’ for the password. When you click ‘Create User’. You’ll notice that the user creation fails because we don’t have a non-alphanumeric character in the password. Go ahead and create a new password that meets the criteria and then create the user.
Ok, now we’ll add some back-end code so we can see what’s happening with our provider. Add the following to your page_load event handler:
gvMembers.DataSource = Membership.GetAllUsers()
gvMembers.DataBind()
txtCurrentConfig.Text = "Password format:" & Membership.Provider.PasswordFormat
What we’re doing is retrieving all our members, and binding them to the GridView. We also pull the PasswordFormat specified for the membership provider and display that in our textbox. If you run your page, you’ll see that we successfully pull our lone member into the GridView, and that our PasswordFormat is 1. This PasswordFormat correlates with the enumeration for PasswordFormats that is defined in the .Net Framework as follows (ok, I’m interpreting what they’ve done but it still stands):
Enum MembershipPasswordFormat
Clear = 0
Encrypted = 2
Hashed = 1
End Enum
You can see that the 1 indicates that we have Hashed passwords. Herein lies our dilemma (yes, we’re finally to it). Let’s say we wanted to use Encrypted passwords instead? (I needed to change this so I could test the different PasswordFormats against the code in the aforementioned post). Normally, we’d just go and change the Membership provider’s details in the web.config. So let’s go do it. Go look in your web.config for the membership provider’s definition… go on, I’ll wait. Haven’t found it? Keep looking. While you’re at it, see if you can find the connectionString for the membership database… Can’t find it either? There must be one, otherwise how would it connect to the database? You’re right, we’ve mysteriously created a provider we don’t know how to configure.
The solution lies in this: our Machine.config has a default membership provider defined. When we created our membership stuff, this default provider is used by… default. We have to override this if we want to customize it. So how do we do this? First, let’s open our machine.config and see if we can find our Membership provider’s definition there. The machine.config can be found at C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\CONFIG\machine.config (assuming you’re %system% folder is Windows). Search down and you’ll find that there is in fact a provider defined here:
<membership>
<providers>
<add name="AspNetSqlMembershipProvider"
type="System.Web.Security.SqlMembershipProvider, System.Web,
Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b03f5f7f11d50a3a"
connectionStringName="LocalSqlServer"
enablePasswordRetrieval="false"
enablePasswordReset="true"
requiresQuestionAndAnswer="true"
applicationName="/"
requiresUniqueEmail="false"
passwordFormat="Hashed"
maxInvalidPasswordAttempts="5"
minRequiredPasswordLength="7"
minRequiredNonalphanumericCharacters="1"
passwordAttemptWindow="10"
passwordStrengthRegularExpression=""/>
</providers>
</membership>
There it is. The current definition of our membership provider. So how do we override it? Simple, we need to define a new one in our web.config code. Somewhere inside your system.web section of the web.config, add a membership provider section and define a new provider as such:
<membership>
<providers>
<add name="myCustomProvider"
type="System.Web.Security.SqlMembershipProvider, System.Web,
Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b03f5f7f11d50a3a"
passwordFormat="Encrypted"
connectionStringName="LocalSqlServer" />
</providers>
</membership>
Notice that we changed our passwordFormat to be Encrypted (so when we run the application, our format should now show as 2. How do we know what connectionStringName to use? We’ll, for one we could just use the same string as the one defined in the provider in the machine.config, but we can also look through the machine.config and see that we have a connectionString defined there too.
<connectionStrings>
<add name="LocalSqlServer"
connectionString="data source=.\SQLEXPRESS;Integrated
Security=SSPI;AttachDBFilename=|DataDirectory|aspnetdb.mdf;User
Instance=true"
providerName="System.Data.SqlClient"/>
</connectionStrings>
You’ll see, that the default connectionString setup in the machine.config is ‘LocalSqlServer’. We could redefine it, but this will work just fine for us. So run your application and see what we get. The member binds fine to the GridView, but we’re still showing that our password is hashed (1). What’s the deal? Well, what’s happening, is we’re still using the default provider in the machine.config. We need to specify that we want to use ours instead. to do this, modify your membership element opening tag with the following attribute addition:
<membership defaultProvider="myCustomProvider">
Here we define that OUR provider should be the default provider. We also can take an additional step recommended my many people on the internet, and that is to remove the default provider from the machine.config. To do that, you can just add the clear element before your <add> provider element as follows:
<providers>
<clear/>
<add name=”…..
Alternatively, we could also remove the provider specifically by using the following in place of the <clear/> element:
<remove name="AspNetSqlMembershipProvider" />
We’re simply telling .Net in to remove the default provider (named AspNetSqlMembershipProvider). Now run your application again. Notice that our passwordFormat is now 2 – Encrypted. BUT, also notice that we no longer are successfully binding our GridView to the member we created. What gives? Add the following line to the end of your page_load event handler and we’ll see:
txtCurrentConfig.Text &= vbCrLf & "Application Name:" & _
Membership.Provider.ApplicationName
Now run your application again. Notice that the applicationName is the name of your website. If you look in your machine.config, what is the applicationName being used? Right, “/” or root. Effectively, by leaving out the applicationName in our membership provider definition, we’ve changed to a different application. Our user is assigned to the root application, and we’re pulling members from a different application (our website name). To fix that, just add the following to your provider’s definition:
applicationName="/"
Run your application again, and you’ll see that now we have our member back. Or better yet, we could create a unique application name for our application and start creating users in that instead. This helps to ensure that our users are unique to our application and not being reused from some other application that used the root application name.
Epilogue
Well, there it is. Now you can configure your SQLExpress membership provider as you desire. There are a load of other settings that we didn’t define in ours (as witnessed by the settings in the machine.config) and defining them is easy as adding the settings.
Funny how something so simple could cause me so much searching all over the internet looking for the solution. Honestly, it took me quite a long time to find the answer, I probably wasted 2 hours or more before I stumbled into the answer. Maybe I’m just dense, it and it should be common knowledge, but it wasn’t to me.
Anyhow, it’s there, and if either of us ever need it, we’ll know where to look for the answer.
Prologue
**Author Note – If you are bewildered by a line (perhaps two) that are red and bold, don’t they that they’re errors, they are corrections/additions. Please see the comments, after the article for comments regarding the highlighted line(s).**
In previous posts, we created “A better ASP.Net Member/Role Management Page”. If you want to follow along, you can create the management page yourself by reviewing all the posts for part 1, part 2, part 3, part 4, part 5 and part 6. In this post, I plan on ‘tying up some loose ends,’ by adding some things that I’d like to have in the management page, but that were over and above basic functionality. Most of this is stuff that I thought of adding while I was creating the page originally, but didn’t want to add yet, or that I didn’t have time to research at the time. Now's my chance to do it.
Problem
Time to add some ‘fluffy stuff’ and add some stuff that will make it just that little bit better… (and I found now that I’ve written this post, add some corrections as well…)
Solution
To get started, let's make it easier to customize the look and feel of the user control.
CssClass Properties
The first thing we’ll look at adding will give you a little more flexibility as far as styling your control. Currently it should look pretty decent but, it looks like Microsoft’s theme, and not our site (I don’t know about yours, but ours doesn’t look like Microsoft’s). I’m sure that we’ll change our site again someday, so it doesn’t make sense to hard code the styles and not have the option to change the styles on the fly. Lucky for us it should be reasonably easy to build in this functionality. I won’t cover how to do every one of them, but if we do it right we should be able to cover a bunch in a couple of groups, and the basics of how it can be done should give you a significant start.
Creating our own styles for the page doesn’t mean we have to remove the styling that we’ve done so far. We’ll just give the user the ability to override our styles to fit their needs. I’ve compiled a list of all the places that I would like to give the user the ability to define the CssClass (I think I got them all, I may have missed some…). We’ll start with the easy ones and show how it’s done and then let you loose on the others. First off, we’ve got three groups of classes that we can set the whole group all at once, our TextBoxWaterMarkExtenders watermark class, our ModalPopupExtender’s background class (the cover for the existing page) and our Panels that will be our modal dialogs (the actual panels being modally displayed). Each of these groups can each be set at once since they’ll be the same (all the watermarks will be the same for instance). Let’s start with our TextBoxWaterMarkExtenders. Here’s the list of controls I compiled:
tweSearchUsersFor
tweSearchUsersForRoles
tweSecurityAnswer (note, this may be misspelled in other posts as tbeSec…)
tweNewUsername (note, this may be misspelled in other posts as tbeNew…)
tweNewRoleName
What we’ll do is create a property for the WaterMarkTextCssClass and then set all their WaterMarkCssClasses when the property is set. Add the following to your back-end code:
Private _TextBoxWatermarkCssClass As String
Public Property TextBoxWatermarkCssClass() As String
Get
Return _TextBoxWatermarkCssClass
End Get
Set(ByVal value As String)
_TextBoxWatermarkCssClass = value
tweSearchUsersFor.WatermarkCssClass = value
tweSearchUsersForRoles.WatermarkCssClass = value
tweSecurityAnswer.WatermarkCssClass = value
tweNewUsername.WatermarkCssClass = value
tweNewRoleName.WatermarkCssClass = value
End Set
End Property
I cheated, I use the Refactor! tool, that I’ve mentioned before, so all I had to do to create the property is write the declaration line and then right click on it and select and refactor. I then added all the TextBoxWaterMarkExtender controls from the list to the set portion of the property and assigned the value passed in to their WatermarkCssClass property. Now go to our test page that tests our user control and in the HTML markup that uses the control, add an attribute for WatermarkCssClass and assign it a css class with different style than the one we’re using (my css class had one line: color: red;). Run it and you’ll see that it overrides the one that we assigned in the control’s front end markup. (Note, I’ve found that when you add properties this way, you may need to close and reopen the page that’s using the control otherwise it may not show our new property as an intellisense available attribute).
One other small note: if you notice I saved the property to a global variable. Realistically, we could just put the value to the controls and then call it good, but if we want to pull it and look at later, it then it might be nice to pull it from a central point rather than from one of the controls.
Next, we’ll do the ModalPopupExtender BackgroundCssClass. The ModalPopupExtenders that I cataloged are:
mpeCreateUser
mpeMessageDialog
mpeDeleteUserConfirm
mpeEditUser
mpeChangePassword
mpeConfirmResetPassword
mpeChangeSecurityQuestion
mpeChangeUsername
mpeDeleteRoleConfirm
mpeChangeRoleName
mpeRoleRenameConfirm
To create their property, add the following to your back-end code:
Private _ModalPopupBackgroundCssClass As String
Public Property ModalPopupBackgroundCssClass() As String
Get
Return _ModalPopupBackgroundCssClass
End Get
Set(ByVal value As String)
_ModalPopupBackgroundCssClass = value
mpeCreateUser.BackgroundCssClass = value
mpeMessageDialog.BackgroundCssClass = value
mpeDeleteUserConfirm.BackgroundCssClass = value
mpeEditUser.BackgroundCssClass = value
mpeChangePassword.BackgroundCssClass = value
mpeConfirmResetPassword.BackgroundCssClass = value
mpeChangeSecurityQuestion.BackgroundCssClass = value
mpeChangeUsername.BackgroundCssClass = value
mpeDeleteRoleConfirm.BackgroundCssClass = value
mpeChangeRoleName.BackgroundCssClass = value
mpeRoleRenameConfirm.BackgroundCssClass = value
End Set
End Property
Nothing much different than we did before, just more controls to assign to. Again, add an attribute to your control declaration on the test page and see the difference if you run it (my popup background is orange now :-0 ). Finally for the easy ones, let’s do the panels that will be displayed as the ModalPopup. The list I compiled are:
pnlMessageDialog
pnlDeleteUserConfirm
pnlCreateUser
pnlEditUser
pnlChangePassword
pnlConfirmResetPassword
pnlChangeSecurityQA
pnlChangeUsername
pnlDeleteRoleConfirmation
pnlChangeRoleName
pnlChangeRoleNameConfirm
And one more time add the property and assign the values. Add the following to the backend code:
Private _ModalPopupCssClass As String
Public Property ModalPopupCssClass() As String
Get
Return _ModalPopupCssClass
End Get
Set(ByVal value As String)
_ModalPopupCssClass = value
pnlMessageDialog.CssClass = value
pnlDeleteUserConfirm.CssClass = value
pnlCreateUser.CssClass = value
pnlEditUser.CssClass = value
pnlChangePassword.CssClass = value
pnlConfirmResetPassword.CssClass = value
pnlChangeSecurityQA.CssClass = value
pnlChangeUsername.CssClass = value
pnlDeleteRoleConfirmation.CssClass = value
pnlChangeRoleName.CssClass = value
pnlChangeRoleNameConfirm.CssClass = value
End Set
End Property
Check it if you want, my control is staring to look pretty odious (I’m choosing some truly awful colors, just for kicks).
For the remaining CssClasses, we have a number of other one-off’s that we’ll need to address individually. I won’t be addressing them in this post, but I’ll list them and then you can add the property code to assign their classes just as we did above. The controls I cataloged were:
tblSearchUsers
tblSearchUsersHeader
tblSearchUsersBody
gvManageUsers – CssClass, RowStyleCssClass, AlternativeRowStyleCssClass
tblCreateUser
tblCreateRole
tblCreateRoleHeader
gvRoles – CssClass, RowStyleCssClass, AlternativeRowStyleCssClass
tblSearchForUsersRole
tblSearchForUsersRoleBody
gvUsersForRole – CssClass, RowStyleCssClass, AlternativeRowStyleCssClass
Two notes: 1. Some of these can likely be combined (such as the GridView’s CssClasses) since I want them to look the same I’ll set them the same time, 2. If you go back trough your code, you’ll notice that the tables and the Header and Body elements here listed (i.e. tblSearchUsers and tblSearchUsersHeader), may need to have an ID attached to them so we can call them in the back-end code (they may not already have ID’s). This means that you’ll be adding an ID to a table (tblSearchUsers) and to a TableCell (tblSearchUsersHeader – as it isn’t truly a header, it’s a TableCell masquerading as one) in your front-end code so you can call it directly in the back-end. Then we’ll create a property that calls it a header (i.e. headerCssClass) – so even some child elements will have ID’s assigned. Create and assign the properties as you did above.
Realistically, any CSS that we created in the front-end, you can setup as a property and override as you desire.
Real-time Username Notification
One of the things I thought would be really great to have from the get go was to add real-time username availability notification. What I mean by that is for those locations where the admin creates or changes the username, allow the dialog to inform the admin whether the username is available AS they are typing. Something akin to this (and the actual demo here).
When I started this project, the authors weren’t fully finished with develpment, but they did finish by the time I finished our control. Unfortunately (in our instance), they created a user control. BUT, wanting to put my control together as a self-contained usercontrol, I didn’t want to add another external control to the mix. I looked into reusing the code and integrating it into my control, and it could be done. What I didn’t think about though is that the front-end page needs to call to something, via Javascript/AJAX as you are typing. Any way it is sliced, (at least from what I found on the site) this requires a web service of some kind on the back-end (that’s the something). Even if I can call a function contained in the same page (which the control alas is NOT, it’s a control not a page), it has to be declared as a web service so that it can be called by the AJAX (there’s an interesting article on using jQuery to call a page method that I read).
To get it to work, the jQuery has to call a page, by name, and then the method (we could pass the page name to the control by property in the front-end and write it into our javascript code so the jQuery can call it (I’ve written on injecting into javascript before). We also would have to enable the page to support web services, which I gather necessitates some changes to the web.config. This then means that we’re not self contained. We have already broken our self contained requirement by adding functionality for changing the username (and will again in the next section), but realistically, to get this functioning, wasn’t worth the time I thought I’d end up putting in. The admin will get notification that the username is not available, it just won’t be while typing.
Role Descriptions
Here’s another interesting bit of functionality that Microsoft left out (and I can’t discern why). Let’s say we want to put in a description of the role so that the admin can go back and remember what each role is meant for (the roles I’m using are descriptive to departments here at the company, but not necessarily to what rights the user gets). Descriptions would be handy. What to do? Well if you examine the roles table in the database, you’ll see that Microsoft conveniently added a description field to the database (nvarchar 256). So it seems that we’d be able to pretty easily enter a description. NOPE. Apparently, this is unimplemented functionality. There is nothing in the role provider to facilitate this. If we want to do it, we’ll have to again break our self-contained directive, and add some database functionality. We can create a property that we’ll use to specify whether the external functionality has been created like we did with our Change Username functionality. But, alas it means breaking the self-contained directive AGAIN.
I’m going break our directive anyway, I believe it will have enough use for me that I’ll go ahead and break our rules again. First we need to start with modifying the front-end code. We want to add another column to our Manage Roles GridView. Add the following to the definition of gvRoles. Add it between the RoleName and 'Assign Users’ columns (as the 2nd column):
<asp:TemplateField HeaderText="Description" Visible="false">
<HeaderStyle HorizontalAlign="Left" />
<ItemTemplate>
<asp:label runat="server" ID="lblRoleDescription"
Text="<%# GetRoleDescription(Container.DataItem.ToString()) %>" />
(<asp:LinkButton runat="server" ID="lnkRoleDescription"
ForeColor="Black" Text='Edit'
CommandName="EditRoleDescription"
CommandArgument='<%# Container.DataItem.ToString() %>'
OnCommand="RoleLinkButtonClick"/>)
</ItemTemplate>
</asp:TemplateField>
You’ll notice that we setup another column for the description, it is composed of 2 parts, a label for the description, and an edit link (you may notice that the control for that is surrounded by parentheses, that’s not a typo, they’re string literals for the HTML). You’ll also notice that we call a function GetRoleDescription. We’ll have to define that in our back-end code later. We also set the column’s visible property false so by default it is NOT shown. I put two controls in our column, one that displays the description name, and one that displays an edit link.
Originally, I thought to put the description functionality with the change role name functionality, however I rethought that, as the change role name is already a special circumstance. We’ll keep them separate which means we’ll add another dialog for changing the description. As for adding a description when the admin creates a role, since the description functionality is not out of the box, I’ll force the admin to create a new role and then take a second step to enter a description.
We’ll want to create a new modal dialog for editing our role description. Add the following to the bottom of our front-end code:
<%-- Change Role description Dialog --%>
<asp:UpdatePanel id="upnlRoleDescription" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlRoleDescription" runat="server"
style="display: none;" CssClass="modalPopup"
HorizontalAlign="Center">
Enter a description for the '<asp:label id="lblRoleDescriptionName"
runat="server" Text="" />' role.<br /><br />
<asp:Table ID="Table1" runat="server">
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Role description:
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:TextBox ID="txtRoleDescription" runat="server" />
<cc1:TextBoxWatermarkExtender ID="tweRoleDescription"
TargetControlID="txtRoleDescription"
WatermarkText="Enter role description."
WatermarkCssClass="watermarked" runat="server" />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<br />
<asp:LinkButton ID="lnkRoleDescriptionSave"
CausesValidation="true" runat="server" Text="Save" />
<asp:LinkButton ID="lnkRoleDescriptionCancel" runat="server"
Text="Cancel" />
</asp:Panel>
<asp:Button ID="btnFakeMPERoleDescription" runat="server"
style="display: none;" />
<cc1:ModalPopupExtender id="mpeRoleDescription" runat="server"
TargetControlID="btnFakeMPERoleDescription"
PopupControlID="pnlRoleDescription"
BackgroundCssClass="modalBackground" />
</ContentTemplate>
</asp:UpdatePanel>
It’s pretty standard for our dialogs. We have one TextBox, for the description and two LinkButtons for save and cancel. In keeping with previous dialogs, let’s display this using our SetUI subroutine. Add the following to our SetUI enumeration:
EnableRoleDescription
RoleDescriptionDialog
Then we’ll add some functionality to our SetUI subroutine. Add the following two cases to your SetUI case statement:
Case SetUIModes.EnableRoleDescription
gvRoles.Columns(1).Visible = True
Case SetUIModes.RoleDescriptionDialog
mpeRoleDescription.Show()
This just takes care of displaying things. The first case will make our column visible if the property is set to true. The second shows the dialog. We’ll also need to add some functionality to our RoleLinkButtonClick event handler. Add the following If statement to your RoleLinkiButtonClick:
If e.CommandName.Equals("EditRoleDescription") Then
lblRoleDescriptionName.Text = CStr(e.CommandArgument)
txtRoleDescription.Text = GetRoleDescription(e.CommandArgument)
SetUI(SetUIModes.RoleDescriptionDialog)
End If
This will set the textbox to the current role description and then show the edit dialog when the admin clicks the ‘Edit’ link.
Next, let’s setup the save and cancel buttons in the role description dialog. Add the following to your back-end code to your lnkRoleDescriptionCancel_Click event handler:
txtRoleDescription.Text = String.Empty
This simply clears our TextBox if we cancel so we don’t have any loose ends. Next add the following to our lnkRoleDescriptionSave_Click event handler:
If UpdateRoleDescription(lblRoleDescriptionName.Text, _
txtRoleDescription.Text) = True Then
lblMessageDialog.Text = "Role description successfully updated."
SetUI(SetUIModes.MessageDialog)
gvRoles.DataSource = Roles.GetAllRoles()
gvRoles.DataBind()
Else
lblMessageDialog.Text = "Changing the role's description was _
unsuccessful."
SetUI(SetUIModes.MessageDialog)
End If
Here we call a UpdateRoleDescription subroutine and pass it the role’s name and the new description. If it returns successful, we’ll rebind the GridView with new data, and display a success message. If it fails, we’ll display a failure message. In our SQL code, we will be retrieving the role by the role’s name. Since we won’t be allowed by our provider to add a role name more than once per ApplicationID (more on that later), we should be ok doing it this way.
We’ll need to define some SQL before proceeding. We’ll create two stored procedures for our database. First, let’s define the GetRoleDescription’s stored procedure:
CREATE PROCEDURE aspnet_Roles_GetRoleDescription
@RoleName nvarchar(255),
@ApplicationName nvarchar(255),
@RoleDescription nvarchar(255) OUTPUT
AS
DECLARE @ApplicationID nvarchar(255)
SELECT @ApplicationID = ApplicationID FROM aspnet_Applications
WHERE ApplicationName = @ApplicationName
SELECT @RoleDescription = Description FROM aspnet_Roles
WHERE RoleName = @RoleName AND ApplicationID = @ApplicationID
First off, we create three SQL variables, two will be input and one output. The input variables will pass in our role name and our application name. We’ll use the application name to pull the ApplicationID and then use the role name and ApplicationID to target the description. We need to include the ApplicationID since we can create two roles with the same name in the database IF they have different applications assigned to them. If we target by role name only, we could be effecting multiple roles, especially when we get to updating the role’s description. Finally we’ll retrieve the description and assign that to our output variable.
Next, we’ll define our UpdateRoleDescription stored procedure as follows:
CREATE PROCEDURE aspnet_Roles_UpdateRoleDescription
@RoleName nvarchar(255),
@ApplicationName nvarchar(255),
@RoleDescription nvarchar(255),
@Results bit OUTPUT
AS
DECLARE @ApplicationID nvarchar(255)
SELECT @ApplicationID = ApplicationID FROM aspnet_Applications
WHERE ApplicationName = @ApplicationName
UPDATE aspnet_Roles SET Description = @RoleDescription
WHERE RoleName = @RoleName AND ApplicationID = @ApplicationID
SET @Results = @@ROWCOUNT
This time we’re creating three input variables, since we need to pass in the new description as well as the role name and application name. Much like the previous stored procedure, we’ll use the application name to retrieve the ApplicationID and then use that to target the correct role. Then we update the description for the role and return the affected row count as a result. This way, we can check the success of our operation. We should only get a 1 back (meaning one row affected). If we get anything else, we’ll have failed.
Now we’re ready to do some hookups in the back-end code. First, we’ll define our GetRoleDescription function. Add the following function to your back-end code:
Public Function GetRoleDescription(ByVal sRoleName As String, _
Optional ByVal iOrdinalForConnectionString As Integer = 0) As String
If _RoleDescriptionFunctionality = _
RoleDescriptionSettings.DbIsSetup Then
Using cn As New SqlConnection(ConfigurationManager.ConnectionStrings
(iOrdinalForConnectionString).ConnectionString)
Dim cmd As New SqlCommand("aspnet_Roles_GetRoleDescription", cn)
cmd.CommandType = Data.CommandType.StoredProcedure
cmd.Parameters.Add("@RoleName", _
Data.SqlDbType.NVarChar).Value = sRoleName
cmd.Parameters.Add("@ApplicationName", _
Data.SqlDbType.NVarChar).Value = Roles.ApplicationName
cmd.Parameters.Add("@RoleDescription", Data.SqlDbType.NVarChar, _
255).Direction = Data.ParameterDirection.Output
cn.Open()
Try
cn.Open()
cmd.ExecuteScalar()
Catch ex As Exception
Return string.empty
Finally
cn.Close()
End Try
If cmd.Parameters("@RoleDescription").Value.Equals _
(System.DBNull.Value) Then
Return String.Empty
Else
Return cmd.Parameters("@RoleDescription").Value
End If
End Using
Else
Return String.Empty
End If
End Function
In the parameter list for our function, you’ll notice we pass in our role name, and we also pass in an optional parameter for our connectionstring. If you remember back to part 5 in the series, we added some database functionality for changing the username. I mentioned when we created the back-end code for ChangeUsername, that this was perhaps the least flexible part of our entire project, since we can’t automatically pull the current connectionstring from anywhere. We need to have a way to retrieve it so we can create a connection to our database though. Since the connectionstrings are in an array, we can specify them by ordinal number. In my simple application here at work, my connectionstring is 0 (the only one). To target another connectionstring for our connection, we would need to determine the ordinal number of the connectionstring and pass it in. (UGLY I agree… excuse me while I hurl…). We may be able to set this via property as we did above with cssClasses so that we don’t have to modify the code to change the connectionstring.
The function creates a connection, and then a command object. We load the command object up with our parameters. Nothing fancy, except one item of note, we specify a size for the return parameter. If we don’t specify the size, our application will return an error about not allowing a string of size 0. We’ll then open the connection and try to execute. We’ll use a try catch and if we get an error, we’ll return an empty string. If we’re successful, we’ll check to see if we got a null value returned. If so, we’ll again return an empty string. If it’s NOT null, we’ll return the description.
Next, let’s define our UpdateRoleDescription function:
Public Function UpdateRoleDescription(ByVal sRoleName As String, _
ByVal sNewDescription As String, _
Optional ByVal iOrdinalForConnectionString As Integer = 0) As Boolean
Using cn As New SqlConnection(ConfigurationManager.ConnectionStrings _
(iOrdinalForConnectionString).ConnectionString)
Dim cmd As New SqlCommand("aspnet_Roles_UpdateRoleDescription", cn)
cmd.CommandType = Data.CommandType.StoredProcedure
cmd.Parameters.Add("@RoleName",_
Data.SqlDbType.NVarChar).Value = sRoleName
cmd.Parameters.Add("@ApplicationName", _
Data.SqlDbType.NVarChar).Value = Roles.ApplicationName
cmd.Parameters.Add("@RoleDescription", _
Data.SqlDbType.NVarChar, 255).Value = sNewDescription
cmd.Parameters.Add("@Results", _
Data.SqlDbType.Bit).Direction = Data.ParameterDirection.Output
cn.Open()
Try
cmd.ExecuteNonQuery()
Catch ex As Exception
Return False
Finally
cn.Close()
End Try
Return cmd.Parameters("@Results").Value
End Using
End Function
Here we’re passing in three parameters, our role name, our new description name, and again our odious connectionstring ordinal. We create a connection and a command, load it up with our data, and then execute the query. If it fails, we return false, if it runs, we’ll return the result (if it fails, it’ll be false that gets returned).
Viola, now we can add role descriptions. Why Microsoft hasn’t bothered to make this part of the Roles provider I’ll never know. I mean why build it into the database if you aren’t going to use it? Anyhow, Role descriptions at our disposal.
Fixing Change Username Functions
In the process of adding role description functionality, I found that I didn’t properly execute our ChangeUsername function. If you remember, in the above section, we passed in the applicationName so that we target only the Role that we want to. Technically, we can have more than one username or more than one role in a table if we add it using a different applicationName. I didn’t take that into account when I put together ChangeUsername. I neglected to use the application name SO, technically the code would change ALL the usernames rather than just the one we want. Let’s make some changes so that it behaves properly. Let’s modify our ChangeUsername function to include an applicationName parameter. Add the following just below our @newUsername parameter in our ChangeUsername function:
cmd.Parameters.Add("@applicationName", _
Data.SqlDbType.NVarChar).Value = Membership.ApplicationName
And then we’ll need to modify our aspnet_Membership_ChangeUsername stored procedure. The SQL to update the stored procedure is as follows (changes are in bold):
ALTER PROCEDURE aspnet_Membership_ChangeUsername
@currentUsername nvarchar(255),
@newUsername nvarchar(255),
@applicationName nvarchar(255),
@results bit OUTPUT
AS
DECLARE @ApplicationID nvarchar(255)
SELECT @ApplicationID = ApplicationID FROM aspnet_Applications
WHERE ApplicationName = @ApplicationName
UPDATE aspnet_Users
SET UserName = @newUsername,
LoweredUserName = LOWER(@newUsername)
WHERE LOWER(UserName) = LOWER(@currentUsername)
AND ApplicationID = @ApplicationID
set @results = @@ROWCOUNT
There, now we properly target our specific username.
One other thing I want to update is to reset the change username dialog when the admin clicks cancel. To do this, all we need to do is add two lines to our lnkChangeUsername_Click event handler. Add these lines right after the End If line, and before the lblCurrentUsername.text line:
txtNewUsername.Text = String.Empty
divUserAvailablity.InnerText = String.Empty
Previously, if we canceled changing the username, the the new username textbox and an error message, if applicable, would be there when we reopened it. Now it won’t, it will be pristine like it’s supposed to be.
Epilogue
And there it is, “A Better Asp.Net Member/Role Management Page”. There were a couple other tings I thought about adding to the mix, like rewriting all the confirmation dialogs so that there is one common dialog that we could reuse everywhere we confirmed things, but I didn’t really want to go through the trouble of rewriting the whole mess just to reduce the code, it wasn’t worth it.
If anyone has something that they added, that they’d like to tell us about, drop me a line and I’ll put it in the comments section. I’m sure we’d all love to see it.
But for me, that’s it for the management page. I’ll be putting it into production now that I’m done writing it (yes, I was still finishing it), and unless there are any issues that people point out or errors that need corrected. I’m calling it good and being done! PHEW!
Prologue
**Author Note – If you are bewildered by a line (perhaps two) that are red and bold, don’t they that they’re errors, they are corrections/additions. Please see the comments, after the article for comments regarding the highlighted line(s).**
In previous posts, we started creating “A Better ASP.Net Member/Role Management Page.” In part 1, we defined our criteria, and in part 2, part 3, part 4 and part 5 we created functionality to do all our user maintenance. In this section, we work on our role maintenance functionality.
Problem
We actually have a fair amount of things to do to maintain roles. We’ll create a grid much like the one for the user list, for the roles. We’ll also create a user GridView, so when we select a role, we can search the users and assign them to the given role (much like the user search functionality that we’ve already created. We’ll also need to create a number of dialog boxes that we can popup to get information or confirmation from the administrator. Once we’re finished, we’ll have functionality to add/remove, assign and change role names.
Solution
First things first. We need to create some user interface portions. Add the following two sections to your front-end code somewhere between our pnlFormatEverything tags:
<%-- Manage Roles --%>
<asp:UpdatePanel ID="upnlManageRoles" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlManageRoles" runat="server" Visible="false">
<asp:Label ID="lblManageRolesTitle"
runat="server" Text="" /><br />
<asp:Label ID="lblManageRolesInstructions" runat="server"
Text="Select <b>Edit Role</b> to view or change the
settings for the roles." />
<br /><br />
<asp:Table ID="tblCreateRole"
CssClass="tableBorders" runat="server">
<asp:TableRow>
<asp:TableCell CssClass="tableHeadersNTitles">
<asp:Literal ID="lblCreateRoleTile" runat="server"
Text="Create New Role:" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell>
<asp:Label ID="lblCreateRole" runat="server"
Text="New Role Name:" />
<asp:TextBox runat="server" ID="txtNewRoleName"/>
<cc1:TextBoxWatermarkExtender ID="tweNewRole"
TargetControlID="txtNewRoleName"
WatermarkCssClass="watermarked"
WatermarkText="Enter new role name"
runat="server" />
<asp:Button id="btnAddRole" runat="server"
Text="Add Role"/>
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<br />
<asp:GridView runat="server" CellPadding="3"
GridLines="Horizontal"
ID="gvRoles"
CssClass="userGridView"
AutoGenerateColumns="false"
AllowPaging="true"
PageSize="7"
UseAccessibleHeader="true">
<RowStyle CssClass="gridRowStyle" />
<AlternatingRowStyle CssClass="gridAlternatingRowStyle" />
<PagerStyle CssClass="gridPagerStyle" />
<PagerSettings mode="Numeric" />
<HeaderStyle CssClass="tableHeadersNTitles"
Font-Bold="true" />
<Columns>
<asp:TemplateField HeaderText="Role Name">
<HeaderStyle HorizontalAlign="Left" />
<ItemTemplate>
<asp:Label runat="server" ID="lblRoleName"
ForeColor="Black"
Text='<%# Container.DataItem.ToString() %>' />
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField>
<HeaderStyle HorizontalAlign="Center" />
<ItemTemplate>
<asp:LinkButton ID="lnkManageUsersInRole"
runat="server" Text="Assign Users"
CommandName="EditUsersInRole"
CommandArgument='<%#
Container.DataItem.ToString() %>' ForeColor="Black"
OnCommand="RoleLinkButtonClick" />
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField>
<ItemTemplate>
<asp:LinkButton ID="lnkDeleteRole" runat="server"
Text="Delete Role" CommandName="DeleteRole"
CommandArgument='<%# Container.DataItem.ToString() %>'
ForeColor="Black"
OnCommand="RoleLinkButtonClick" />
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField>
<HeaderStyle HorizontalAlign="Left" />
<ItemTemplate>
<asp:LinkButton ID="lnkEditRoleName" runat="server"
Text="Change Role Name"
CommandName="EditRoleName"
CommandArgument='<%# Container.DataItem.ToString() %>'
ForeColor="Black"
OnCommand="RoleLinkButtonClick" />
</ItemTemplate>
</asp:TemplateField>
</Columns>
</asp:GridView>
<asp:Label runat="server" ID="lblNoRoles" Visible="false"
Text="No roles have been created." />
<br /><br />
<asp:LinkButton ID="lnkManagerUsers" runat="server"
Text="Manager Users" />
</asp:Panel>
</ContentTemplate>
</asp:UpdatePanel>
<%-- Assign/Remove Users from Role --%>
<asp:UpdatePanel ID="upnlAddRemoveUsersFromRole"
runat="server" ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlAddRemoveUsersFromRole" runat="server"
Visible="false">
<asp:Label ID="lblAddRemoveUsersFromRoleTitle" runat="server"
Text="" /><br />
<asp:Label ID="lblAddRemoveUsersFromRoleInstructions"
runat="server"
Text="Use this page to manage the members in the specified role. To add a user to the role, search for the user name and then select User Is In Role for that user. " />
<br /><br />
Role:
<b>
<asp:Literal runat="server" ID="lblRoleToAddRemoveUsersTo" />
</b>
<asp:Table id="tblSearchForUsersRole" runat="server"
CssClass="tableBorders">
<asp:TableRow>
<asp:TableCell>
<asp:Literal ID="lblSearchForUsersRole" runat="server"
Text="Search For Users" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell CssClass="tableBody">
<asp:Label id="lblSearchForUsersRoleSearchBy"
runat="server" Text="Search By: " />
<asp:DropDownList ID="ddlSearchForUsersRole"
runat="server">
<asp:ListItem id="item3" Text="Username"
runat="server" />
<asp:ListItem id="item4" Text="Email" runat="server" />
</asp:DropDownList>
<asp:Label ID="lblSearchForRoles"
runat="server" Text="for: " />
<asp:TextBox id="txtSearchUsersForRoles"
runat="server" />
<cc1:TextBoxWatermarkExtender
ID="tweSearchUsersForRoles"
TargetControlID="txtSearchUsersForRoles"
WatermarkCssClass="watermarked"
WatermarkText="Enter criteria" runat="server" />
<asp:Button id="btnSearchUsersForRoles"
runat="server" Text="Search For" />
<br />
<asp:Label runat="server" ID="lblWildcardAllowed"
Text="Wildcards * and ? are permitted." />
<br />
<asp:Repeater runat="server"
ID="rptAlphabetRepeaterRoles" >
<ItemTemplate>
<asp:LinkButton runat="server" id="lnkLetterRoles"
CommandName="Display"
CommandArgument="<%# Container.DataItem %>"
Text="<%# Container.DataItem %>" />
</ItemTemplate>
</asp:Repeater>
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<br />
<asp:GridView runat="server" CellPadding="3"
GridLines="Horizontal" ID="gvUsersForRole"
CssClass="userGridView" AutoGenerateColumns="false"
AllowPaging="true" PageSize="7" UseAccessibleHeader="true">
<RowStyle CssClass="gridRowStyle" />
<AlternatingRowStyle CssClass="gridAlternatingRowStyle" />
<PagerStyle CssClass="gridPagerStyle" />
<PagerSettings mode="Numeric" />
<HeaderStyle CssClass="tableHeadersNTitles"
Font-Bold="true" />
<Columns>
<asp:TemplateField HeaderText="Username">
<HeaderStyle HorizontalAlign="Left" />
<ItemTemplate>
<asp:Label ID="lblUsernameRole" runat="server"
ForeColor="Black"
Text="<%# Container.DataItem %>" />
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField HeaderText="User is in role">
<HeaderStyle HorizontalAlign="Center" />
<ItemStyle HorizontalAlign="Center" />
<ItemTemplate>
<asp:CheckBox runat="server" ID="chkUserInRole"
OnCheckedChanged="ChangeUserIsAssignedToRole"
AutoPostBack="true"
Checked='<%# IsUserInRole(Container.DataItem) %>' />
</ItemTemplate>
</asp:TemplateField>
</Columns>
</asp:GridView>
<asp:Label runat="server" ID="lblNoUsersFoundRoles"
visible="false" EnableViewState="false"
Text="No users found for this search." />
<br /><br />
<asp:LinkButton runat="server" ID="lnkManageAllRoles"
Text="Manage All Roles" />
<asp:LinkButton runat="server" ID="lnkManageUsersFromRoles"
Text="Manage Users" />
</asp:Panel>
</ContentTemplate>
</asp:UpdatePanel>
You may want to put it towards the top, because putting it to the bottom will cause it to actually display too far toward the bottom. This is due to the fact that a lot of the other sections are styled with display:none. Doing this will leave a section of the rendered page for the sections’ outputs (even if they’re hidden) and push our role management stuff down so it looks weird. Since the other sections are mostly modal dialogs, we can put them at the bottom and they’ll still work as designed.
Before we get to far into it, we’ll also want to add some stuff to our SetUI code block. We’ll be managing a number of panels and UI pieces on various clicks, so let’s add the following to our SetUIModes enumeration:
EditUsersInRole
ManageAllRoles
DeleteRoleConfirm
RenameRoleConfirm
RenameRole
and the following case statements to our SetUI case statement:
Case SetUIModes.EditUsersInRole
pnlManageRoles.Visible = False
pnlAddRemoveUsersFromRole.Visible = True
Case SetUIModes.ManageUsers ‘this line may exist already in your code…
pnlManageRoles.Visible = False
pnlAddRemoveUsersFromRole.Visible = False
pnlManageUsers.Visible = True
Case SetUIModes.ManageAllRoles
pnlManageRoles.Visible = True
pnlManageUsers.Visible = False
pnlAddRemoveUsersFromRole.Visible = False
Case SetUIModes.DeleteRoleConfirm
mpeDeleteRoleConfirm.Show()
Case SetUIModes.RenameRoleConfirm
mpeRoleRenameConfirm.Show()
Case SetUIModes.RenameRole
mpeChangeRoleName.Show()
You may have noticed that we previously (in part 3) created a case statement for ManageUsers, we just never populated the code for the case. We can now add this functionality, so just add the code for that case as follows:
pnlManageRoles.Visible = False
pnlAddRemoveUsersFromRole.Visible = False
pnlManageUsers.Visible = True
Now we can get down to coding the pieces we need to make it all functional. First we’ll want to enable the ‘Manage Roles’ link so that we can access our manage role information. This is pretty easy, add the following to your lnkManageRoles_Click event handler:
SetUI(SetUIModes.ManageAllRoles)
SetDataSource(Roles.GetAllRoles, ROLES_DATA_SOURCE)
BindGrid(gvRoles, ROLES_DATA_SOURCE, True)
gvRoles.DataSource = Roles.GetAllRoles()
gvRoles.DataBind()
This will hide our user GridView, and display our Roles GridView. In addition, it will store our datasource and then populate our Roles GridView with all the roles in the database.
Continuing with the easy stuff, once we switch over to the Manage Roles side, we’ll have a link that takes us back to managing users, we should also hook that up. Add the following to your lnkManageUsers_Click event handler:
SetUI(SetUIModes.ManageUsers)
Really all we’re doing is hiding one and showing the other, not much to it.
Next, let’s hook up our Add Role functionality. Add the following to your btnAddRole_Click event handler:
Try
Roles.CreateRole(txtNewRoleName.Text)
txtNewRoleName.Text = String.Empty
gvRoles.DataSource = Roles.GetAllRoles()
gvRoles.DataBind()
Catch ex As Exception
lblMessageDialog.Text = ex.Message
SetUI(SetUIModes.MessageDialog)
End Try
This is a little more complex, but still pretty simple. We put our attempt to add the role within a try catch block so that if something goes wrong: A. it doesn’t crash, and B. we can let the administrator know what the matter is. We’ll first attempt to create the role, if we get an error, this is where it will happen. If adding the role is successful, then we’ll clear the textbox, and then rebind our GridView with fresh data. If adding the role fails, we’ll display our message dialog box, and show the administrator the error that was returned in the exception.
Next, we’ll concentrate on the links in each row of the GridView. Let’s start with the ‘Delete Role’ link. If you notice in our front-end code, each or our links in the row of our GridView calls ‘RoleLinkButtonClick’ when they are clicked. This means that we’ll need to create a RoleLinkButtonClick event handler in our code. We’ll need to include information typical to an event handler as far as parameters are concerned. Since all the links call the same event handler, we’ll need to determine which was selected, we’ll use a if then statements for that. Create your event handler as follows:
Public Sub RoleLinkButtonClick(ByVal sender As Object, _
ByVal e As CommandEventArgs)
‘If e.CommandArgument.Equals("DeleteRole") Then
If e.CommandName.Equals("DeleteRole") Then
End If
End Sub
We’ve just created a shell for our event handler and for our delete role section of it. Before we actually delete anything we’ll want to confirm with the administrator that they want to delete the role. We’ll need to create a confirmation dialog in our front-end so that we can display it. Add the following to your front-end code. It is a dialog, so it can go towards the bottom if you so desire:
<%-- Delete Role Confirmation Dialog --%>
<asp:UpdatePanel ID="upnlDeleteRoleConfirmation" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlDeleteRoleConfirmation" runat="server"
CssClass="modalPopup" style="display:none; text-align:center;">
<asp:Literal runat="server" ID="lblDeleteRoleConfirmMessage"
Text="" />
<asp:Literal runat="server" ID="lblRoleToDelete" Visible=”False” />
<br><br />
<asp:Button runat="server" ID="btnDeleteRoleYes" Text="Yes" />
<asp:Button runat="server" ID="btnDeleteRoleNo" Text="No" />
</asp:Panel>
<asp:Button runat="server"
ID="btnFakeShowMPEDeleteRoleConfirmButton"
style="display: none;" />
<cc1:ModalPopupExtender ID="mpeDeleteRoleConfirm" runat="server"
TargetControlID="btnFakeShowMPEDeleteRoleConfirmButton"
PopupControlID="pnlDeleteRoleConfirmation"
BackgroundCssClass="modalBackground" />
</ContentTemplate>
</asp:UpdatePanel>
We create a dialog, not much of note, a yes and a no button. The one exception is a hidden literal control that we can use to later pull the associated username. With the dialog complete, we can add the innards to our event handler. Add the following to the If statement for ‘DeleteRole’:
lblRoleToDelete.Text = CStr(e.CommandArgument)
lblDeleteRoleConfirmMessage.Text = "Are you sure you want to delete role: "
& lblRoleToDelete.Text
SetUI(SetUIModes.DeleteRoleConfirm)
This just loads up our dialog, and then show it. Now we need to hookup our ‘Yes’ and ‘No’ buttons. First, let’s hookup our ‘No’ button, add the following to our btnDeleteRoleNo_Click event handler:
mpeDeleteRoleConfirm.Hide()
Basically, we just hide the dialog when the user clicks ‘No’. Now, for our ‘Yes’ button, add the following to our btnDeleteRoleYes_Click event handler:
Try
Roles.DeleteRole(lblRoleToDelete.Text)
gvRoles.DataSource = Roles.GetAllRoles()
gvRoles.DataBind()
Catch ex As Exception
lblMessageDialog.Text = ex.Message()
SetUI(SetUIModes.MessageDialog)
End Try
Again, we’ve got a try catch block in case something terrible happens. We’ll attempt to delete the role, if it fails, we’ll jump to the catch portion of the bock and display the failure message. If it is successful, we’ll refresh the GridView and display the new data.
Next, well hookup the functionality for assigning users to the role. First, we’ll want to setup a constant for our Users_In_Role datasource add the following constant to your back-end code:
Private Const USERS_IN_ROLE_DATA_SOURCE As String = _
"USERS_IN_ROLE_DATA_SOURCE"
Then, we’ll setup a helper function to handle binding the users in role grid and accounting for an empty dataset. Add the following helper subroutine to your code:
Public Sub BindUsersForRoleGrid()
gvUsersForRole.DataSource = theSession(ROLES_DATA_SOURCE)
gvUsersForRole.DataSource = _
theSession(USERS_IN_ROLE_DATA_SOURCE)
gvUsersForRole.DataBind()
If gvUsersForRole.Rows.Count = 0 Then
lblNoUsersFoundRoles.Visible = True
End If
End Sub
We’re creating a subroutine that will populate our GridView’s data source and then examine the GridView. If it is empty, then we’ll show the message about no users being assigned to to the role.
Now we’re ready to add the actual functionality for when we click the ‘Assign Users’ link in the role’s row. Add the following to your RoleLinkButtonClick event handler, after the End If for DeleteRole:
If e.CommandName.Equals("EditUsersInRole") Then
lblRoleToAddRemoveUsersTo.Text = CStr(e.CommandArgument)
PopulateAlphabetRepeater(rptAlphabetRepeaterRoles)
SetDataSource(Roles.GetUsersInRole _
(lblRoleToAddRemoveUsersTo.Text), ROLES_DATA_SOURCE)
SetDataSource(Roles.GetUsersInRole _
(lblRoleToAddRemoveUsersTo.Text), _
USERS_IN_ROLE_DATA_SOURCE)
BindUsersForRoleGrid()
SetUI(SetUIModes.EditUsersInRole)
End If
With that added, we should be able to click our ‘Assign Users’ link and have our ‘Users in Role’ GridView displayed (if there are users in the role). You’ll notice though, that there are a couple of links at the bottom to allow us to navigate back to other editing functions, but they don’t currently work. Let’s hook them up so we can return. Add the following to our lnkManageAllRoles_Click event handler:
SetUI(SetUIModes.ManageAllRoles)
and add the following to our lnkManageUsersFromRoles_Click event handler:
SetUI(SetUIModes.ManageUsers)
Now we should be able to return to previous screens without problem. If you run our application, select to manage roles and then select to assign users to a role, only the users that are currently assigned to our role will be displayed in the GridView. To see other users and assign them to the role, we’ll need to hookup the functionality for the alphabet repeater and search portion at the top. First, let’s create a helper function. This function will take a MembershipUserCollection and convert it to a string array containing usernames. We’ll do this because when we pull Roles.GetUsersInRole, we’ll get a string array, but when we pull from the Membership.FindUser functions, we’ll get a MembershipUserCollection back. Since we’re binding both lists to the same GridView (depending on how we search), it’s a lot easier if they both go to our BindGrid as the same data types. So, add the helper function as follows:
Private Function ConvertMUCollToStringArray( _
ByVal theMembershipCollection As MembershipUserCollection) As String()
Dim usersInRole As New List(Of String)
For Each user As MembershipUser In theMembershipCollection
usersInRole.Add(user.UserName)
Next
Return usersInRole.ToArray()
End Function
This will iterate through the collection, and put the users in a format that we can return as an array. Next, we’ll add functionality for clicking on a letter. Add the following to the rptAlphabetRepeaterRoles_ItemCommand event handler:
gvUsersForRole.PageIndex = 0
Dim arg As String = e.CommandArgument.ToString()
Dim users As MembershipUserCollection = Nothing
If arg.ToLower() = "all" Then
users = Membership.GetAllUsers()
Else
users = Membership.FindUsersByName(arg + "%")
End If
SetDataSource(ConvertMUCollToStringArray(users), _
ROLES_DATA_SOURCE)
BindGrid(gvUsersForRole, ROLES_DATA_SOURCE, True)
SetDataSource(ConvertMUCollToStringArray(users), _
USERS_IN_ROLE_DATA_SOURCE)
BindGrid(gvUsersForRole, USERS_IN_ROLE_DATA_SOURCE, True)
If gvUsersForRole.Rows.Count = 0 Then
lblNoUsersFoundRoles.Visible = True
End If
First, we’ll reset the PageIndex on our Gridview, and the pull the argument that was passed in (the letter that was clicked). Next, we’ll create an object to hold the the users found when searching for our argument, and then pull the appropriate subset of users. Next, we set our datasource (notice that in the process we convert our users object through our helper function to a string array), and then bind our GridView. Finally, we’ll check to see if we have an empty dataset and if so, display a message that no users were found.
Finally, in relation to our search portion, we’ll hookup the search button for the text entry portion. Add the following to our btnSearchUsersForRoles_Click event handler:
Dim users As MembershipUserCollection = _
SearchForUsers(ddlSearchForUsersRole, txtSearchUsersForRoles)
SetDataSource(ConvertMUCollToStringArray(users), ROLES_DATA_SOURCE)
BindGrid(gvUsersForRole, ROLES_DATA_SOURCE, True)
If gvUsersForRole.Rows.Count = 0 Then
lblNoUsersFoundRoles.Visible = True
End If
When the user clicks the search button, we’ll retrieve a collection of MembershipUser objects, meeting our criteria. Then we’ll set our datasource, once again converting to string array, and then bind our GridView with the results. Finally, we’ll check for empty dataset, and display a message if no users are found.
Next, let’s add functionality for the ‘User is in Role’ checkbox. When we check or uncheck it, it will update the database adding or removing the user from the role. In our front-end code, we specify that the checkbox’s onCheckChange event calls the ‘ChangeUserIsAssignedToRole’ subroutine. Define it as follows:
Public Sub ChangeUserIsAssignedToRole(ByVal sender As Object, _
ByVal e As EventArgs)
Dim theCheckbox As CheckBox = CType(sender, CheckBox)
Dim theItem As GridViewRow = _
CType(theCheckbox.Parent.Parent, GridViewRow)
Dim theLabel As Label = _
CType(theItem.FindControl("lblUsernameRole"), Label)
If theCheckbox.Checked = True Then
Roles.AddUserToRole(theLabel.Text, _
lblRoleToAddRemoveUsersTo.Text)
Else
Roles.RemoveUserFromRole(theLabel.Text, _
lblRoleToAddRemoveUsersTo.Text)
End If
End Sub
First, we retrieve checkbox that was clicked (it’s passed in as a parameter), and then use that to retrieve the GridViewRow containing the checkbox (the checkbox’s parent’s parent). From that we’ll get the username. If the checkbox is now checked, we’ll add the user to the role, if not, we’ll remove the user from the role.
That finishes all the functionality for the ‘Assign Users’ link. Next, we’ll concentrate on the ‘Change Role Name’ functionality. First, we’ll need to create some front-end dialogs to support this functionality. We’ll need to create a confirmation dialog, and we’ll need to create a dialog that allows us to input the new name. We also need a dialog to confirm the name change. Add the following to your front-end code anywhere inside our pnlFormatEverything panel:
<%-- Change Role Name Dialog --%>
<asp:UpdatePanel id="upnlChangeRoleName" runat="server" ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlChangeRoleName" runat="server"
style="display: none;" CssClass="modalPopup"
HorizontalAlign=”center”>
Enter a new role name.<br /><br />
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Current role name:
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:Label runat="server" ID="lblCurrentRoleName" />
</asp:TableCell>
<asp:TableCell></asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
New role name:
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:TextBox ID="txtNewEditRoleName" runat="server" />
<cc1:TextBoxWatermarkExtender ID="tweNewRoleName"
TargetControlID="txtNewEditRoleName"
WatermarkText="Enter new role name"
WatermarkCssClass="watermarked" runat="server" />
</asp:TableCell>
<asp:TableCell>
<asp:RequiredFieldValidator ID="rfvNewRoleName"
runat="server" Display="Dynamic" text="*"
ControlToValidate="txtNewEditRoleName"
ValidationGroup="ChangeRoleName"
ErrorMessage="You must enter a new role name." />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell ColumnSpan="3">
<asp:ValidationSummary ID="vsNewRoleName"
runat="server" EnableClientScript="true"
DisplayMode="List" ShowSummary="true"
ValidationGroup="ChangeRoleName" />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<br />
<asp:LinkButton ID="lnkRenameRoleSave"
CausesValidation="true" runat="server" Text="Save"
ValidationGroup="ChangeRoleName" />
<asp:LinkButton ID="lnkRenameRoleCancel" runat="server"
Text="Cancel" />
</asp:Panel>
<asp:Button ID="btnFakeShowMPEChangeRoleName" runat="server"
style="display: none;" />
<cc1:ModalPopupExtender id="mpeChangeRoleName" runat="server"
TargetControlID="btnFakeShowMPEChangeRoleName"
PopupControlID="pnlChangeRoleName"
BackgroundCssClass="modalBackground" />
</ContentTemplate>
</asp:UpdatePanel>
<%-- Role rename confirm dialog --%>
<asp:UpdatePanel ID="upnlChangeRoleNameConfirm" runat="server">
<ContentTemplate>
<asp:Panel ID="pnlChangeRoleNameConfirm" runat="server"
style="display:none;" CssClass="modalPopup"
HorizontalAlign="Center">
<asp:Literal ID="lblRoleToRename" runat="server" isible="false" />
<asp:Literal ID="lblConfirmRoleRename" runat="server" Text="" />
<br /><br />
<asp:Button ID="btnRoleRenameYes" runat="server" Text="Yes" />
<asp:Button ID="btnRoleRenameNo" runat="server" Text="No" />
</asp:Panel>
<asp:Button ID="btnFakeShowMPERoleRenameConfirm" runat="server"
style="display:none;" />
<cc1:ModalPopupExtender runat="server"
ID="mpeRoleRenameConfirm"
TargetControlID="btnFakeShowMPERoleRenameConfirm"
PopupControlID="pnlChangeRoleNameConfirm"
BackgroundCssClass="modalBackground" />
</ContentTemplate>
</asp:UpdatePanel>
Now, we just need to add the functionality. We’ll need to add another block to our RoleLinkButtonClicked event handler. Add the following If Then statement to the subroutine:
If e.CommandName.Equals("EditRoleName") Then
lblCurrentRoleName.Text = CStr(e.CommandArgument)
mpeChangeRoleName.Show()
End If
Basically, we’re just displaying our change role name dialog. We do populate a label in the dialog so that the administrator knows what role is being changed. Next, we need to add functionality to our ‘Save’ and ‘Cancel’ buttons. Add the following to our lnkRenameRoleSave_Click event handler:
lblRoleToRename.Text = lblCurrentRoleName.Text
lblConfirmRoleRename.Text = "Warning, if the role name you are changing is used in your code, renaming a role may break your code. Please proceed with caution!</br><br>Are you sure you want to rename '" & lblRoleToRename.Text & "' to '" & txtNewRoleName.Text & "'?"
SetUI(SetUIModes.RenameRoleConfirm)
We store our role name so that we have it available when we click ‘Yes’, and set a long warning to the administrator letting them know that if they rename a role, they could possibly break their code. Renaming roles is another one of those features that is unsupported by Microsoft. In Microsoft’s defense, it really is a questionable practice though. If you have specified anything in your application calling the role by name, when you rename it here, it will no longer trigger the appropriate functionality in your application, thus breaking your code. (USE THIS FEATURE WISELY…) Add the following to our lnkRenameRoleCancel_Click event handler:
txtNewEditRoleName.Text = String.Empty
lblCurrentRoleName.Text = String.Empty
This just clears out anything hanging around when we cancel the dialog.
Finally, we’ll want to hook up our ‘Yes’ and ‘No’ buttons on our confirmation dialog. We’ll start with ‘No’. Add the following to our btnRoleRenameNo_Click event handler:
SetUI(SetUIModes.RenameRole)
We will just go back to the change role dialog if the administrator doesn’t confirm. Next, add the following to our btnRoleRenameYes_Click event handler:
Dim usersInRole As String() = Roles.GetUsersInRole(lblRoleToRename.Text)
Roles.CreateRole(txtNewEditRoleName.Text)
If usersInRole.Length > 0 Then
Roles.AddUsersToRole(usersInRole, txtNewEditRoleName.Text)
Roles.RemoveUsersFromRole(usersInRole, lblRoleToRename.Text)
End If
Roles.DeleteRole(lblRoleToRename.Text)
txtNewEditRoleName.Text = String.Empty
lblRoleToRename.Text = String.Empty
SetUI(SetUIModes.ManageAllRoles)
gvRoles.DataSource = Roles.GetAllRoles()
gvRoles.DataBind()
You’ll notice, that we can’t just rename the role, the functionality isn’t included in the Microsoft Role provider, so we have to work around it. What we’ll do, is retrieve the users in the role that we want to rename, we’ll check that string array that we retrieve, and if it has any elements, we know that there are users in the role. We’ll add these users to our new role (after we create it) and then remove those users from the old role, and then delete the old role (since we can’t delete a role with users in it). (That’s our work around, add all the users from our old role to the new role, remove them from the old role, and then delete the old role). Once we are done, we’ll clear our dialog and then close it. Finally, we’ll rebind our GridView so that it displays our new role name.
**Author’s Note** – I found one more small problem, paging for the Role’s grids don’t work. Unfortunately in my development, I didn’t put more than one page worth of anything into my database (users or roles). Thus, I never checked to make sure my paging worked, I never needed to page. As a result, paging for both gvRoles and gvUsersForRole will return a JavaScript error. This is because we don’t handle their PageIndexChanging event handler. Since we’re using AJAX, the JavaScript will call the back-end, but cause an error because the event handler doesn’t exist. So to remedy this problem we’ll add the following lines to our gvRoles_PageIndexChanging event handler:
gvRoles.PageIndex = e.NewPageIndex
BindGrid(gvRoles, ROLES_DATA_SOURCE, False)
and the following to our gvUsersForRole_PageIndexChanging event handler:
UsersForRole.PageIndex = e.NewPageIndex
BindGrid(gvUsersForRole, ROLES_DATA_SOURCE, False)
BindGrid(gvUsersForRole, USERS_IN_ROLE_DATA_SOURCE, False)
You’ll also notice that I made a little change in our lnkManageRoles_click event handler. To be able to use paging, we need to store our datasource, so rather than binding our gvRoles directly from our Roles provider (Roles.GetAllRoles()), we’ll set our DataSource and then use it later when we call our PageIndexChanging event handler.
Now paging works. Funny what you find when you do some real testing…
Epilogue
And there we have it. The final pieces of our functionality. All we need to manage all aspects of our Roles. We can now Add, Delete, Rename, and assign/unassign users from our roles.
Technically, we’re done with our control, we’ve done everything we need to do to successfully use it, however, I think that I’ll do one more post in this series offering some optional enhancements that I may someday use if I feel like it, but that may come in useful none the less.
Prologue
**Author Note – If you are bewildered by a line (perhaps two) that are red and bold, don’t they that they’re errors, they are corrections/additions. Please see the comments, after the article for comments regarding the highlighted line(s).**
In previous posts we started creating “A Better ASP.Net Member/Role Management Page.” In part 1, we set criteria, and in part 2, part 3 and part 4 we created functionality to add, delete and edit users. We’ve finished nearly all the functionality for editing users with one exception, changing usernames. We’ll be addressing that in this post.
Problem
Changing a username is functionality that isn’t included in the default Microsoft membership provider. The reason for this, in my understanding, is that changing usernames is considered somewhat of a security issue. Really,i don’t understand why it isn’t included, changing a username could be put together in such a way that it is reasonably secure, you know require passwords and or security questions etc. like practically everything else that you try to change using the membership provider does. Although the functionality isn’t included in the Microsoft provider, we can add the functionality on our own (and it’s actually relatively easy).
Solution
Unfortunately, this functionality will be the one part that we won’t be able to contain fully in our page, we’ll have to add a stored procedure to our database. Well, I guess we could put all our SQL stuff in a subroutine, but that’s leaves us with a security problem, accepting user in put and just inserting into a SQL statement and running it directly form our page leaves us open for SQL injection attack. The most secure thing would be to create a stored procedure in the database, and like I said that means that we don’t have a fully self contained page.
To workaround this limitation, what we’ll do is add a property to the page so that we can indicate that we have created the stored procedure and we can change the username. This will turn on/off the ‘change username’ link on our page.
Let’s start with the SQL stored procedure. If you’ve ever looked at a database that’s enabled for the ASP.net membership provider you will notice that enabling a database for ASP.Net membership provider adds a number of stored procedures. We’ll add another stored procedure and we’ll use the same naming convention. To add the stored procedure execute the following SQL code:
CREATE PROCEDURE aspnet_Membership_ChangeUsername
@currentUsername nvarchar(255),
@newUsername nvarchar(255),
@results bit OUTPUT
AS
UPDATE aspnet_Users
SET UserName = @newUsername,
LoweredUserName = LOWER(@newUsername)
WHERE LOWER(UserName) = LOWER(@currentUsername)
set @results = @@ROWCOUNT
The stored procedure here will take three parameters, one for each of the usernames, the new and the old, and one parameter to output the success of the operation. It will return 0 on failure and 1 on success (as one row was changed on success). The actual username data is actually only stored in one location, in the user table. Rather than using the username as the ID to link with all other tables, there’s a unique UserID in the user table that is used. Changing the username is easy, we have two places to change it in one table and it doesn’t take much.
Once that’s done, we need to add the functionality to our page. We don’t have much to add to the front-end code, just the change username dialog and a new style. Add the following to your style section:
.taken
{
color: Red;
}
and the following to your front end code. Place it at the end of the page, but before our closing tag for our pnlFormatEverything panel:
<%-- Change Username Dialog --%>
<asp:UpdatePanel id="upnlChangeUsername" runat="server" ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlChangeUsername" runat="server"
style="display: none;
text-align:center;" CssClass="modalPopup">
<b>Enter a New Username</b><br /><br />
<asp:Table runat="server" ID="tblChangeUsername">
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Current Username:
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:Label ID="lblCurrentUsername"
runat="server" Text="" />
</asp:TableCell>
<asp:TableCell></asp:TableCell>
</asp:TableRow>
<asp:Tablerow>
<asp:TableCell HorizontalAlign="Right">
New Username:
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:TextBox ID="txtNewUsername" runat="server" />
<div runat="server" id="divUserAvailablity"
class="taken"></div>
<cc1:TextBoxWatermarkExtender
ID="tbeNewUsername" runat="server"
TargetControlID="txtNewUsername"
WatermarkText="Enter New Username"
WatermarkCssClass="watermarked">
</cc1:TextBoxWatermarkExtender>
</asp:TableCell>
<asp:TableCell>
<asp:RequiredFieldValidator runat="server"
ID="rfvNewUsername"
ControlToValidate="txtNewUsername"
Display="Dynamic"
ValidationGroup="ChangeUsername"
ErrorMessage="You must enter a new username"
Text="*" />
</asp:TableCell>
</asp:Tablerow>
<asp:TableRow>
<asp:TableCell ColumnSpan="3">
<asp:ValidationSummary ID="vsNewUsername"
ValidationGroup="ChangeUsername" runat="server"
DisplayMode="List" ShowSummary="true"
EnableClientScript="true" />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<asp:LinkButton ID="lnkChangeUsernameSave"
runat="server" Text="Save"
CausesValidation="true" ValidationGroup="ChangeUsername" />
<asp:LinkButton ID="lnkChangeUsernameCancel"
runat="server" Text="Cancel" />
</asp:Panel>
<asp:Button ID="btnFakeShoeMPEChangeUsernameDialogButton"
runat="server" style="display:none;" />
<cc1:ModalPopupExtender ID="mpeChangeUsername" runat="server"
TargetControlID="btnFakeShoeMPEChangeUsernameDialogButton"
PopupControlID="pnlChangeUsername"
BackgroundCssClass="modalBackground">
</cc1:ModalPopupExtender>
</ContentTemplate>
</asp:UpdatePanel>
Basically we’re just creating a label to put in the existing username, a textbox for the new username and then save and cancel buttons. We also add a div so that we can tell the user if the new username is already being used.
Now let’s add the back end code. First, we’ll add another entry to our SetUI enumeration, and another case to our SetUI case statement. Add the following line to our enumeration:
ChangeUsername
and add the following case statement to our SetUI Case statement:
Case SetUIModes.ChangeUsername
mpeChangeUsername.Show()
Next, we’ll hook up the ‘Change Username’ LinkButton. It’s functionality is pretty easy. Add the following to the our lnkChangeUsername_Click event handler:
lblCurrentUsername.Text = lblUsernameEdit.Text
SetUI(SetUIModes.ChangeUsername)
We set the current username and then show the dialog.
We don’t need to do anything with the cancel button as it will function correctly without help, however we need to hook up the functionality for the Save button. We’ll want to do a little checking to see if the username is being used before we actually do any changing. We’ll want to display a message that it’s taken if it is. We’ll also want to make sure that the username was successfully changed in the database when we actually execute the SQL. Well add two helper functions, the first to check username availability. Add the following function:
Private Function CheckUsernameIsAvailable( _
ByVal sUsernameToCheck As String) As Boolean
If IsNothing(Membership.GetUser(sUsernameToCheck)) = False Then
Return False
Else
Return True
End If
End Function
Basically, we receive the new username and then check to see if we can retrieve a MembershipUser object from the database. If we can, then the username is NOT available and we return false.
We’ll also want to create a helper function to perform the actual database work, since our membership provider doesn’t do this for us. Add the following helper function (I apologize in advance for dividing of the lines in such a terrible manner…):
Private Function ChangeUsername(ByVal sCurrentUsername As String, _
ByVal sNewUsername As String, _
Optional ByVal iOrdinalForConnectionString As Integer = 0) As Boolean
Using cn As New SqlConnection( _
ConfigurationManager.ConnectionStrings( _
iOrdinalForConnectionString).ConnectionString)
Dim cmd As New SqlCommand _
("aspnet_Membership_ChangeUsername", cn)
cmd.CommandType = Data.CommandType.StoredProcedure
cmd.Parameters.Add _
("@currentUsername", Data.SqlDbType.NVarChar).Value _
= sCurrentUsername
cmd.Parameters.Add _
("@newUsername", Data.SqlDbType.NVarChar).Value _
= sNewUsername
cmd.Parameters.Add("@Results", Data.SqlDbType.Bit).Direction _
= Data.ParameterDirection.Output
cn.Open()
Try
cn.Open()
cmd.ExecuteNonQuery()
Catch ex As Exception
Return False
Finally
cn.Close()
End Try
Return cmd.Parameters("@Results").Value
End Using
End Function
This function is easily the least flexible in the entire object. Notice we pass in both the old and the new usernames. We also pass in an ordinal for the connectionString to use. Unfortunately we are unable to determine the connectionString currently being used in the membership provider, so we have to pass in the ordinal of the connection string we are using. In my case, I don’t even have a connectionString as I’m using the ASP.Net default sqlexpress database, so an ordinal of 0 works for me. You may need to play with what you pass in depending on your connectionstring setup. We use this connectionString to create a connection. With this connection, we create a command object, load it up with our parameters, including an output parameter. We then attempt to execute the stored procedure. If it fails we return false, otherwise we return the success/failure results from the actual database execution. You’ll notice that we use some SQL Data client objects so we’ll need to import the following namespace (or fully qualify all your SQL objects). Add the following import statement to the top of your document:
Imports System.Data.SqlClient
With our helper functions out of the way, we can now concentrate on our save routine. Add the following to your lnkChangeUsernameSave_Click event handler:
If CheckUsernameIsAvailable(txtNewUsername.Text) = False Then
divUserAvailablity.InnerText = "Username taken, sorry."
SetUI(SetUIModes.ChangeUsername)
Exit Sub
End If
If ChangeUsername(lblCurrentUsername.Text, txtNewUsername.Text) = True Then
SetDataSource(Membership.GetAllUsers(), USERS_DATA_SOURCE)
BindGrid(gvManageUsers, USERS_DATA_SOURCE, False)
lblMessageDialog.Text = "Username successfully changed."
Else
lblMessageDialog.Text = "Username change failed."
End If
txtNewUsername.Text = String.Empty
divUserAvailablity.InnerText = String.Empty
SetUI(SetUIModes.MessageDialog)
First we check to make sure the username is available. If it isn’t, then we’ll put up a message much like our validation letting the administrator know that it’s taken. We’ll make sure our change username dialog is still up and then exit the sub. If it’s valid, we attempt to perform the actual change. If the change is successful, we’ll refresh our data source, and then update our GridView with the new username information. Whether it fails or is successful, we’ll set the message dialog, and then when all is said and done, show the message. We’ll also reset our new username TextBox and our availability div so that they are both blank and ready to use next time.
Ok, so we’ve got all our functionality working, but what if a user of our control didn’t put the stored procedure into the database? We’ll need to disable the functionality in that case. Actually, what we’ll do is disable the functionality be default, and then add it back in if the control’s user specifically enables it. We’ll add a property to our user control that will control the ‘Change username’ link so that the functionality to call the DB is only available if the control’s user specifically configures it to say that the DB has been setup. Add the following to define our property:
Public Enum ChangeUsernameSettings
DbIsSetup
DbIsNotSetup
DisableLink
End Enum
Private _ChangeUsernameFunctionality As ChangeUsernameSettings _
= ChangeUsernameSettings.DbIsNotSetup
Public Property ChangeUsernameFunctionality()
Get
Return _ChangeUsernameFunctionality
End Get
Set(ByVal value)
_ChangeUsernameFunctionality = value
End Set
End Property
Here, we create a enumeration for our different settings. Next we create a private variable to hold our property’s current setting, and set it by default to be DbIsNotSetup. Finally we create our property.
Now we’ll need to implement some things so that the settings are used. Add the following line to your SetupEditUser subroutine (anywhere will work):
If _ChangeUsernameFunctionality = ChangeUsernameSettings.DisableLink _
Then lnkChangeUsername.Enabled = False
If disableLink is selected, then we’ll disable it during our EditUser dialog setup. We could add a tooltip if desired, letting the administrator using the page what needs to be done to add the functionality. Next add the following to your lnkChangeUsername_Click event handler (apologies for the big block of text in the middle that has no link breaks (for readability)):
If _ChangeUsernameFunctionality = _
ChangeUsernameSettings.DbIsNotSetup Then
lblMessageDialog.Text = _
"For this fundtion to be available, the following must be met: <br> 1. The database needs to have a stored procedure created for this functionality and <br> 2. the administrator must explicitly control to specify that the functinality is in the database.<br><br> Please see the documentation for more information."
SetUI(SetUIModes.MessageDialog)
Exit Sub
End If
If our page creator doesn’t specifically enable on this functionality, the administrator using our control will receive a message letting them know what needs to happen for the functionality to be enabled. And of course, if we set the property to DbIsSetup, then we’ll have our functionality back.
Epilogue
Ugh… as in ugly. Truly, this isn’t the cleanest portion of our functionality, we are completely disregarding just about every one of our rules about keeping this control self contained and trying to make it useful across the board. But, in our defense, we’re making up for functionality limitations built into the the .Net framework (or is it limitations NOT built into…?). We could of course extend, or rewrite our membership provider, but I choose not to do it that way.
This should fulfill all the requirements we had previously for our membership portion of our management page. Now we just need to work on the roles administration portion. That will be the subject of the next post(s).
Prologue
**Author Note – If you are bewildered by a line (perhaps two) that are red and bold, don’t they that they’re errors, they are corrections/additions. Please see the comments, after the article for comments regarding the highlighted line(s).**
Previously, we started work on “A better ASP.Net Member/Role Management Page”. So far, we looked at requirements in Part 1, we then added functionality in Part 2 to display, filter and search existing user and finally, in Part 3, we added functionality to create and delete users from the system. In this post, we’ll be adding functionality to edit a user’s account information.
Problem
In this post, we’ll be creating the functionality we need to edit users. This is perhaps one of the most convoluted lengthy parts of our series. We’ll run into a number of places where the functionality isn’t as simple as it seems or that we have limits based on how we’ve configured our provider. Once we’re finished with this post, we should have completed all of our requirements to complete #3 on our criteria list, with the exception of renaming (which will be addressing in the next post).
Solution
Let's get started by creating all the front end markup for our Edit User dialog. We’re going to introduce a lot of functionality in this post, and this markup will encapsulate most of it. The front-end code is kind of unwieldy, but here it is. I’ll point out a couple of noteworthy items after the listing. Add the following after your last section, but before the closing tag for our formatEverything panel:
<%-- Edit User Dialog –%>
<asp:UpdatePanel ID="upnlEditUser" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlEditUser" runat="server" CssClass="modalPopup"
style="display:none;">
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell Width="75%">
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell ColumnSpan="3">
<b>Edit User Details:</b>
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Username:
</asp:TableCell>
<asp:TableCell>
<asp:Label ID="lblUsernameEdit"
runat="server" Text="" />
</asp:TableCell>
<asp:TableCell>
<asp:LinkButton ID="lnkChangeUsername"
runat="server" text="Change Username" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Email:
</asp:TableCell>
<asp:TableCell>
<asp:TextBox ID="txtEmail" runat="server" />
<cc1:TextBoxWatermarkExtender ID="tweEmail"
runat="server" TargetControlID="txtEmail”
WatermarkCssClass="watermarked"
WatermarkText="Enter eMail">
</cc1:TextBoxWatermarkExtender>
</asp:TableCell>
<asp:TableCell>
<asp:RegularExpressionValidator ID="revEmail"
runat="server" ErrorMessage="eMail not valid."
ControlToValidate="txtEmail" Display="Dynamic"
ValidationExpression="\S+@\S+\.\S+" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Description:
</asp:TableCell>
<asp:TableCell>
<asp:TextBox ID="txtDescription" runat="server" />
<cc1:TextBoxWatermarkExtender ID="tweDescription"
TargetControlID="txtDescription"
WatermarkCssClass="watermarked"
WatermarkText="Enter description"
runat="server">
</cc1:TextBoxWatermarkExtender>
</asp:TableCell>
<asp:TableCell>
<asp:LinkButton ID="lnkResetPassword"
runat="server" Text="Reset Password" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Password:
</asp:TableCell>
<asp:TableCell>
<asp:Label ID="lblPassword" runat="server"
Text="********" />
</asp:TableCell>
<asp:TableCell>
<asp:LinkButton ID="lnkChangePassword"
runat="server" Text="Change Password" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Security Question:
</asp:TableCell>
<asp:TableCell>
<asp:Label ID="lblSecurityQuestion"
runat="server" Text="" />
</asp:TableCell>
<asp:TableCell>
<asp:LinkButton ID="lnkChangeSecurityQuestion"
runat="server" Text="Change Security Q/A" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Active User:
</asp:TableCell>
<asp:TableCell>
<asp:CheckBox ID="chkActiveUser"
runat="server" Text="" Checked="true" />
</asp:TableCell>
<asp:TableCell></asp:TableCell>
</asp:TableRow>
</asp:Table>
</asp:TableCell>
<asp:TableCell>
<b>
<asp:Label ID="lblEditUserSelectRoles"
runat="server" Text=”Roles” />
</b>
<br />
<asp:Label ID="lblEditAddUserToRole"
runat="server" Text="" />
<br />
<asp:Repeater ID="rptEditUserRoles" runat="server">
<ItemTemplate>
<asp:CheckBox ID="chkRole" runat="server"
Text="<%# Container.DataItem.ToString() %>" />
<br />
</ItemTemplate>
</asp:Repeater>
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell ColumnSpan="2" HorizontalAlign="Center">
<asp:LinkButton ID="lnkEditUserSave" runat="server"
Text="Save" />
<asp:LinkButton ID="lnkEditUserCancel" runat="server"
Text="Cancel" CausesValidation=”False” />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
</asp:Panel>
<asp:Button ID="btnFakeMPEEditUser" runat="server"
style="display:none;" />
<cc1:ModalPopupExtender ID="mpeEditUser" runat="server"
TargetControlID="btnFakeMPEEditUser"
PopupControlID="pnlEditUser"
BackgroundCssClass="modalBackground">
</cc1:ModalPopupExtender>
</ContentTemplate>
</asp:UpdatePanel>
I’m going against my anti-table-as-layout leanings again, and doubling up on that breach of protocol by nesting tables. In our code, we create a two row table, in the first row, we add almost everything except the buttons that will save or cancel. In the second row we add the save and cancel buttons. The first row has 2 cells one that holds a table with all the user information, and another with the role information. You’ll notice that we have a couple of WatermarkExtenders for some of the text boxes.
We also have one minor change to our cancel button over other’s we’ve used. You’ll notice that we add CausesValidation=”false”. This will allow our button to close the dialog even if the entries in our form are not valid (otherwise we’d have to get everything valid before we can even cancel).
You’ll also notice that we have a number of items that we’ll link to yet other dialogs that provide the actual functionality (i.e. ‘change password’, ‘change username’ and ‘change security Q/A’). These are a little more tricky to implement. For this reason, the actual functionality for these will not be directly effected by the ‘Edit User’ dialog, rather they will work semi-independently of the dialog (i.e. they will be available from the ‘Edit User’ dialog, but will not function based on clicking save/cancel on the ‘Edit User’ dialog).
With the front-end markup code finished, we’re ready to get started on the back-end coding. Flip over to your code behind and let’s get started. First we’ll want to hookup our ‘Edit User’ link to open our new dialog. If you remember, our Delete User link’s event handler was the GridUsersClick subroutine. We’ll need to add another if statement to our GridUsersClick event handler to catch the ‘Edit User’ click. Add the following to your GridUsersClick subroutine, it doesn’t matter if it is before or after the DeleteUser’s if statement:
If e.CommandName.Equals("EditUser") Then
SetupEditUser(e.CommandArgument)
SetUI(SetUIModes.EditUser)
End If
We’ll call a helper function to do a number of little things that it takes to populate our ‘Edit User’ dialog. We are also passing the CommandArgument from the GridUsersClick to the routine, this will pass our username over to the setup routine so we know which user’s information to pull. Create our SetupEditUser subroutine as follows:
Private Sub SetupEditUser(ByVal sUsername As String)
lblUsernameEdit.Text = sUsername
Dim theUser As MembershipUser = Membership.GetUser(sUsername)
If IsNothing(theUser) Then Exit Sub
txtEmail.Text = theUser.Email
txtDescription.Text = theUser.Comment
chkActiveUser.Checked = theUser.IsApproved
lblSecurityQuestion.Text = theUser.PasswordQuestion
SetDataSource(Roles.GetAllRoles, ROLES_DATA_SOURCE)
BindRepeater(rptEditUserRoles, ROLES_DATA_SOURCE)
SetupEditUserRoles(sUsername)
End Sub
First we grab the username and use it to retrieve the membership information for the user. The object that is returned has all the user’s accessible information. If a valid object is returned, then we’ll process the remainder of the routine. We pull each piece of information and display it. Then we bind the repeater for Roles with all the roles, and call another helper function SetupEditUserRoles. We’ll use this to iterate through our repeater items and mark the roles that our user is currently assigned to. Finally we SetUI so that our ‘Edit User’ dialog will appear. To make this helper routine function we need to add a couple things. First, let’s add an entry to the SetUIModes Enumeration for ‘EditUser’ and add the following case to our SetUI subroutine:
Case SetUIModes.EditUser
mpeEditUser.Show()
We also need to create our SetupEditUserRoles helper function so let’s define it as follows:
Private Sub SetupEditUserRoles(ByVal sUserName As String)
If rptEditUserRoles.Items.Count > 0 Then
lblEditAddUserToRole.Text = "Select Roles for user:"
For Each theItem As RepeaterItem In rptEditUserRoles.Items
Dim chkRole As CheckBox = _
CType(theItem.FindControl("chkRole"), CheckBox)
chkRole.Checked = Roles.IsUserInRole(sUserName, chkRole.Text)
Next
Else
lblEditAddUserToRole.Text = "No roles defined."
End If
End Sub
If our repeater has any items, then we’ll add administrator directions to our ‘Edit User’ dialog, and then iterate through each of the items in the repeater. We pull out the checkbox from the repeater’s item and if see if the user is in the role associated with the checkbox. If they are, then we’ll mark it checked. If there are not any roles, we’ll also notify the administrator of such.
Running your application at this point should allow you to select ‘Edit User’ and get all the details about our user. If we have roles, they’ll show up and if we have any assigned to the user, they’ll be indicated as such. Now we need to hookup our ‘Save’ button so that our changes are saved. (‘Cancel’ already automatically closes the dialog without saving). Add the following within our lnkEditUserSave_Click event handler:
If Not Page.IsValid() Then Return
Dim sUserName As String = lblUsernameEdit.Text
Try
Dim theEditedUser As MembershipUser = _
Membership.GetUser(sUserName)
theEditedUser.Email = txtEmail.Text
theEditedUser.Comment = txtDescription.Text
theEditedUser.IsApproved = chkActiveUser.Checked
Membership.UpdateUser(theEditedUser)
UpdateRoleMembership(rptEditUserRoles, sUserName)
Catch ex As Exception
lblMessageDialog.Text = "Error updating user and/or roles."
SetUI(SetUIModes.MessageDialog)
Return
End Try
First we check if the page is valid (our validation controls will set this). If it isn’t we’ll exit, if it is we’ll retrieve the username and use that to retrieve their MembershipUser object. From there we’ll update the pieces of the User’s information and then save the changes to the database. Finally, we update the role membership. We already created the helper subroutine to assign roles, so it should perform without any trouble. We wrap this in a try catch block so that if it fails we can notify the administrator that update failed. You may also notice that if update fails, we reuse the dialog that we created earlier to notify the administrator.
Now that we’ve saved the user’s new information we need to update our GridView’s datasource (and thus the GridView). We’ll also notify our administrator that the changes were successful. Add the following to our lnkEditUserSave_Click event handler. Below our End Try statement:
SetDataSource(Membership.GetAllUsers(), USERS_DATA_SOURCE)
BindGrid(gvManageUsers, USERS_DATA_SOURCE, True)
lblMessageDialog.Text = "You have successfully updated the user <b>" _
& sUserName & "</b>."
SetUI(SetUIModes.MessageDialog)
ResetEditUserDialog()
Here we reset our data source and then rebind our GridView. We set our message dialog and then show it. Finally we call a new helper subroutine, ResetEditUserDialog. This will clear out all the information that we put into our ‘Edit User’ dialog. We can also call this when we click the cancel button if desired. This just makes sure that we don’t have any stray information hanging around. Create the new helper subroutine as follows:
Private Sub ResetEditUserDialog()
lblUsernameEdit.Text = String.Empty
txtDescription.Text = String.Empty
txtEmail.Text = String.Empty
lblSecurityQuestion.Text = String.Empty
End Sub
Really all we are doing is flushing out all the data in our dialog box. Now if you run your application and edit a user’s information, we’ll be able to successfully change and update the user’s information.
You may have noticed, but we still have a checkbox in the main GridView that we haven’t wired up with anything but a stub. (the checkbox in front of the user’s name). We can wire that up pretty easily. If you remember back, to the gridUsersClick portion, we’ll need to define some parameters to receive information from the actual event (since currently our stub doesn’t have them). From there we’ll be able to work our magic. Modify the ChangeUserIsApproved stub as follows:
Public Sub ChangeUserIsApproved(ByVal sender As Object, _
ByVal e As EventArgs)
With these parameters, we can then access the properties that we need to change the user’s approved status. Add the following as the body of your subroutine and we’ll be good to go there:
Dim theCheckbox As CheckBox = CType(sender, CheckBox)
Dim theItemRow As GridViewRow = CType(theCheckbox.Parent.Parent, _
GridViewRow)
Dim theUserNameLabel As Label = _
CType(theItemRow.FindControl("lblUsername"), Label)
Dim sUserName As String = theUserNameLabel.Text
Dim theUser As MembershipUser = _
Membership.GetUser(sUserName)
theUser.IsApproved = theCheckbox.Checked
Membership.UpdateUser(theUser)
First we get the checkbox. Then we retrieve the GridViewRow containing the checkbox so that we can get the user’s name. Finally we pull the label with the username from the GridViewRow and use that to retrieve the user from the Membership database. We change the user’s IsApproved status and then the update the user.
That leaves us with 4 LinkButtons on this page that we haven’t yet addressed, ‘Change Username’, ‘Reset Password’, ‘Change Password’ and ‘Change Security Q/A’. I saved these till last because your ability as an administrator to change/reset a user’s password and security Q/A depends on how the Membership provider is configured.
If you aren’t familiar with the setup of membership providers in ASP.Net, I found an excellent article that covers the provider settings and how they affect what you are allowed to do with passwords. Basically if we have set our provider up as the default (HASHED), then we don’t have the option as administrators to change the user’s password, we can only reset it. Also, even if we set our password as encrypted, then we’ll not be able to change the user’s password if our provider sets requiresQuestionAndAnswer to true, because we’d have to have the user’s security answer.
The point is this, if we want to have the ability as an administrator to change the user’s password, we’ll either need to have the user’s password and/or security question or configure our provider in such a way that we can retrieve the password. Here’s the rub, we may not want to configure our provider that way for the users, however it may be appropriate for the administrator. One suggestion I found in my searching revolves around having 2 Membership providers and then using one for the users and one for the administrator. The two providers would both go to the same Membership database, but the difference would be in how you setup your provider details (such as enablePasswordRetrieval etc).
In our case, what we’ll do is examine the provider being used and determine if it meets the requirements necessary to allow us to perform the functions we’d like, if not then we’ll disable the feature. You may decide to try changing your membership provider around a little to test it in each of the scenarios. We’ll use the flowchart on the right to determine what options we present to the administrator.
We’ll want to enable or disable the links on our ‘Edit User’ dialog before it is shown, based on the provider’s settings. While we could do this in the page_load, we’ll create a subroutine that has all our functionality in it and call that subroutine from our editUserClick subroutine. Define our helper subroutine as follows:
Private Const FUNCTIONALITY_NOT_ENABLED As String = _
"Provider not configured to allow this function."
Public Sub SetupEditPasswordLinks()
Dim bResetEnabled As Boolean = Membership.Provider.EnablePasswordReset
Dim bRetrievalEnabled As Boolean = Membership.Provider.EnablePasswordRetrieval
Dim bQnARequired As Boolean = Membership.Provider.RequiresQuestionAndAnswer
If Membership.Provider.PasswordFormat = MembershipPasswordFormat.Hashed Then
lnkChangePassword.Enabled = False
lnkChangePassword.ToolTip = FUNCTIONALITY_NOT_ENABLED
lnkChangeSecurityQuestion.Enabled = False
lnkChangeSecurityQuestion.ToolTip = FUNCTIONALITY_NOT_ENABLED
If bResetEnabled = False OrElse _
(bResetEnabled = True AndAlso bQnARequired = True) Then
lnkResetPassword.Enabled = False
lnkResetPassword.ToolTip = FUNCTIONALITY_NOT_ENABLED
End If
Else
If bRetrievalEnabled = False OrElse _
(bRetrievalEnabled = True AndAlso bQnARequired = True) Then
lnkChangePassword.Enabled = False
lnkChangePassword.ToolTip = FUNCTIONALITY_NOT_ENABLED
End If
If bResetEnabled = False OrElse _
(bResetEnabled = True AndAlso bQnARequired = True) Then
lnkResetPassword.Enabled = False
lnkResetPassword.ToolTip = FUNCTIONALITY_NOT_ENABLED
End If
If bRetrievalEnabled = False Then
lnkChangeSecurityQuestion.Enabled = False
lnkChangeSecurityQuestion.ToolTip = FUNCTIONALITY_NOT_ENABLED
End If
End If
If bRetrievalEnabled = False Then
lnkChangeSecurityQuestion.Enabled = False
lnkChangeSecurityQuestion.ToolTip = FUNCTIONALITY_NOT_ENABLED
End If
End Sub
In this subroutine, we run through the scenarios in our flowchart and enable/disable our links for changing things dependant on our provider’s settings. We also add an if statement to enable/disable our Change Security Q/A link if password retrieval is not enabled (as the password needs to be passed to the method to perform the change). Now we just need to call our helper subroutine. Add the following line to our gridUsersClick subroutine, just after the SetupEditUser call:
SetupEditPasswordLinks()
You should now have your links enabled or disabled as appropriate to your provider configuration. Now we just need to make all the links functional. First, we’ll need to create dialog boxes that allow us to enter the information for each of our functions. Add the following to our front-end code, following our last section, but before our formatEverything panel end tag:
<%-- Change Password Dialog --%>
<asp:UpdatePanel ID="upnlChangePassword" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlChangePassword" runat="server"
Style="display: none" CssClass="modalPopup">
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
New Password:
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:TextBox ID="txtNewPassword"
runat="server" TextMode="Password" />
</asp:TableCell>
<asp:TableCell>
<asp:RequiredFieldValidator ID="rfvNewPassword"
runat="server" ControlToValidate="txtNewPassword"
Text="*" Display="Dynamic"
ErrorMessage="New password cannot be blank."
ValidationGroup="ChangePassword" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
Confirm Password:
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:TextBox ID="txtConfirmNewPassword"
runat="server" TextMode="Password" />
</asp:TableCell>
<asp:TableCell>
<asp:CompareValidator ID="cvConfirmPassword"
runat="server"
ControlToValidate="txtConfirmNewPassword"
ControlToCompare="txtNewPassword"
ValidationGroup="ChangePassword"
ErrorMessage="The passwords do not match."
Display="Dynamic" Text="*" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell ColumnSpan="3">
<asp:ValidationSummary id="vsChangePassword"
runat="server" ValidationGroup="ChangePassword"
DisplayMode="List" ShowSummary="true"
EnableClientScript="true" />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<br /><br />
<asp:LinkButton ID="lnkChangePasswordSave" runat="server"
Text="Save" CausesValidation="true" />
<asp:LinkButton ID="lnkChangePasswordCancel"
runat="server" />
</asp:Panel>
<asp:Button ID="btnFakeShowMPEChangePassword"
runat="server" style="display: none" />
<cc1:ModalPopupExtender ID="mpeChangePassword"
runat="server"
TargetControlID="btnFakeShowMPEChangePassword"
PopupControlID="pnlChangePassword"
BackgroundCssClass="modalBackground">
</cc1:ModalPopupExtender>
</ContentTemplate>
</asp:UpdatePanel>
<%-- Confirm Reset Password Dialog --%>
<asp:UpdatePanel ID="upnlConfirmReset" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlConfirmResetPassword" runat="server"
style="display:none; text-align:center" CssClass="modalPopup">
<asp:Label ID="lblConfirmResetPasswordInstructions"
runat="server" />
<asp:Button id="btnResetPasswordYes"
runat="server" Text="Yes" />
<asp:Button ID="btnResetPasswordNo"
runat="server" Text="No" />
</asp:Panel>
<asp:Button id="btnFakeMPEConfirmResetPassword"
runat="server" Style="display:none;" />
<cc1:ModalPopupExtender ID="mpeConfirmResetPassword"
runat="server"
TargetControlID="btnFakeMPEConfirmResetPassword"
PopupControlID="pnlConfirmResetPassword"
CancelControlID="btnResetPasswordNo"
BackgroundCssClass="modalBackground">
</cc1:ModalPopupExtender>
</ContentTemplate>
</asp:UpdatePanel>
<%-- Change Security Question Dialog --%>
<asp:UpdatePanel ID="upnlChangeSecurityQA" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlChangeSecurityQA" runat="server"
Style="display:none;" CssClass="modalPopup" >
<asp:Label ID="lblChangeSecurityQAInstructions" runat="server"
Text="Change your Security Question and/or Answer: " />
<br /><br />
<asp:Table runat="server">
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
<asp:Label ID="lblChangeSecurityQuestion"
Text="Security Question:"
runat="server" />
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:TextBox ID="txtSecurityQuestion" runat="server" />
</asp:TableCell>
<asp:TableCell>
<asp:RequiredFieldValidator runat="server"
Display="Dynamic" ID="rfvSecurityQuestion"
ControlToValidate="txtSecurityQuestion" Text="*"
ErrorMessage="Security Question cannot be blank."
ValidationGroup="SecurityQuestion" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell HorizontalAlign="Right">
<asp:Label ID="lblChangeSecurityAnswer"
Text="Security Answer:" runat="server" />
</asp:TableCell>
<asp:TableCell HorizontalAlign="Left">
<asp:TextBox ID="txtSecurityAnswer" runat="server" />
<cc1:TextBoxWatermarkExtender
ID="tbeSecurityAnswer" runat="server"
TargetControlID="txtSecurityAnswer"
WatermarkText="Enter Security Answer"
WatermarkCssClass="watermarked">
</cc1:TextBoxWatermarkExtender>
</asp:TableCell>
<asp:TableCell>
<asp:RequiredFieldValidator runat="server"
Display="Dynamic" ID="rfvSecurityAnswer"
ControlToValidate="txtSecurityAnswer" Text="*"
ErrorMessage="Security Answer cannot be blank."
ValidationGroup="SecurityQuestion" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell ColumnSpan="3">
<asp:ValidationSummary ID="vsSecurityQuestion"
runat="server" DisplayMode="List"
ShowSummary="true" EnableClientScript="true"
ValidationGroup="SecurityQuestion" />
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<asp:LinkButton ID="lnkChangeSecurityQASave" runat="server"
CausesValidation="true" Text="Save" />
<asp:LinkButton ID="lnkChangeSecurityQACancel" runat="server"
Text="Cancel" />
</asp:Panel>
<asp:Button id="btnFakeMPEChangeSecurityQuestion"
runat="server" Style="display:none;" />
<cc1:ModalPopupExtender ID="mpeChangeSecurityQuestion"
runat="server"
TargetControlID="btnFakeMPEChangeSecurityQuestion"
PopupControlID="pnlChangeSecurityQA"
CancelControlID="lnkChangeSecurityQACancel"
BackgroundCssClass="modalBackground">
</cc1:ModalPopupExtender>
</ContentTemplate>
</asp:UpdatePanel>
Now let’s add functionality to use all the stuff we just added. First we need to add some to our SetUI helper subroutine. Add the following lines to our SetUIModes Enumeration:
ChangePassword
ResetPassword
ChangeSecurityQuestion
and then add the following cases to our SetUI helper Subroutine:
Case SetUIModes.ChangePassword
mpeChangePassword.Show()
Case SetUIModes.ResetPassword
mpeConfirmResetPassword.Show()
Case SetUIModes.ChangeSecurityQuestion
mpeChangeSecurityQuestion.Show()
Finally, we can add functionality to make our links work. For our ‘Change Password’ link add the following:
Protected Sub lnkChangePassword_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles lnkChangePassword.Click
SetUI(SetUIModes.ChangePassword)
End Sub
Protected Sub lnkChangePasswordSave_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles lnkChangePasswordSave.Click
Dim mu As MembershipUser = Membership.GetUser(lblUsernameEdit.Text)
Dim sOldPassword As String = mu.GetPassword()
Try
mu.ChangePassword(sOldPassword, txtNewPassword.Text)
lblMessageDialog.Text = "Password successfully changed."
Catch ex As Exception
lblMessageDialog.Text = ex.Message.ToString()
End Try
SetUI(SetUIModes.MessageDialog)
End Sub
Protected Sub lnkChangePasswordCancel_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles lnkChangePasswordCancel.Click
SetUI(SetUIModes.EditUser)
End Sub
First when we click our ‘Change Password’ link, we use our SetUI function to show the modal popup for changing the password. We also define functionality for the two buttons in the change password dialog (save and cancel). When we click Cancel, we just need to set our UI back to the ‘Edit User’ dialog. When we click the save button, we want to pull the username, get the MembershipUser object associated with it and use that to change the password. If we run into an error (it’s likely due to improper password specifications – i.e. the password is not long enough, or doesn’t have enough non-alphanumeric characters etc.), we’ll just display an error message that tells the administrator the reason for failure and go back to our GridView.
Note: that the error handling here isn’t really all that clean. We’re just telling the administrator the problem and then dumping them back to the GridView rather than to the ‘Edit User’ dialog. For sake of ease, I haven’t gone into a lot of depth in the error handling, but we could devise a means of dumping the administrator back to the ‘Edit User’ dialog after the message dialog instead and create a cleaner way of checking the password’s characteristics and reporting the error to the user.
Next, we’ll handle the ‘Reset Password’' link. Add the following to your code:
Protected Sub lnkResetPassword_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles lnkResetPassword.Click
lblConfirmResetPasswordInstructions.Text = _
"Are you sure you want to reset user: " & lblUsernameEdit.Text & _
"'s password? This cannot be undone."
SetUI(SetUIModes.ResetPassword)
End Sub
Protected Sub btnResetPasswordYes_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles btnResetPasswordYes.Click
Membership.GetUser(lblUsernameEdit.Text).ResetPassword()
lblMessageDialog.Text = "Password successfully reset."
SetUI(SetUIModes.MessageDialog)
End Sub
Protected Sub btnResetPasswordNo_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles btnResetPasswordNo.Click
SetUI(SetUIModes.EditUser)
End Sub
With the reset button, we don’t actually have a dialog to enter anything, rather we have a dialog that confirms our decision to reset the password. When we click the ‘Reset Password’ link we’ll setup our dialog with a message that’s customized with the username to be reset, and then display our confirmation message. If the administrator clicks ‘No’, then we just go back to the ‘Edit User’ dialog. If the admin clicks ‘Yes’, then we reset the user’s password and then notify the administrator that the password was successfully reset. Again, success will dump our administrator back to the GridView. We could add additional functionality to change that but I chose not to.
Lastly for this post, we’ll add functionality for changing the user’s security question. Add the following to your code:
Protected Sub lnkChangeSecurityQuestion_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles lnkChangeSecurityQuestion.Click
txtSecurityQuestion.Text = String.Empty
txtSecurityAnswer.Text = String.Empty
SetUI(SetUIModes.ChangeSecurityQuestion)
End Sub
Protected Sub lnkChangeSecurityQASave_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles lnkChangeSecurityQASave.Click
Dim mu As MembershipUser = Membership.GetUser(lblUsernameEdit.Text)
mu.ChangePasswordQuestionAndAnswer(mu.GetPassword(), _
txtSecurityQuestion.Text, txtSecurityAnswer.Text)
lblMessageDialog.Text = "Security Question and Answer successfully changed."
SetUI(SetUIModes.MessageDialog)
End Sub
Protected Sub lnkChangeSecurityQACancel_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles lnkChangeSecurityQACancel.Click
SetUI(SetUIModes.EditUser)
End Sub
When the administrator clicks the ‘Change Security Q/A’ link, we’ll blank out fields in the Security Question form (to reset it if was used previously), and then display the dialog. If the administrator clicks ‘Cancel’, we’ll pop back to the ‘Edit User’ dialog. If the administrator clicks ‘Save’, we’ll pull the MembershipUser object for the user and then change their security question. When that is successful, we’ll display a message of success to the administrator and then pop back to the GridView.
Run you application and test out your links. If you play with your provider’s settings you’ll see that the relevant links will be enabled/disabled depending on your settings and you should be able to perform password changes and resets depending on your configurations.
Epilogue
Phew! We’ve covered a lot of ground today. We added nearly all the functionality needed to edit all aspects of our user’s account. This added a lot of code to both our front-end and back-end files (we’re not done yet). In the next segment, I’ll be talking about the one edit user piece we didn’t implement in this section, changing a username. This gets a little more complicated as it requires us to add some functionality calling directly to the database. Probably this segment was the longest code wise, from here out our code should be a little shorter, as we won’t be adding quite so much functionality at once.
Prologue
**Author Note – If you are bewildered by a line (perhaps two) that are red and bold, don’t they that they’re errors, they are corrections/additions. Please see the comments, after the article for comments regarding the highlighted line(s).**
In previous posts, we outlined criteria in Part 1 for creating a “Better ASP.Net Member/Role Management Page”, and in Part 2 created functionality for displaying the users in our database as well as searching and filtering them. We want to continue developing our page.
Problem
In this post, we’ll be hooking up the functionality that we need to create a user in our system. Once we’ve got this functionality working, we won’t have to look outside our own page to insert users for testing purposes. We’ll use some AJAX to create a “popup” that will display a form to create our user and then use the same AJAX functionality to display a message to the administrator that the user was created successfully. Finally, we’ll add functionality to delete a user. We’ll popup a confirmation dialog for the administrator so they have to confirm user deletion.
Solution
To create a user, we have a couple of options, we can call the Membership.CreateUser method (and we create the user form-which gives us more flexibility) or we can use a CreateUserWizard control and let it do most of the work for us (including creating the form elements and performing the validation, even validating against our provider’s definition stipulations such as password strength etc.). Since Microsoft’s Web Site Admin Tool (WSAT) uses the CreateUserWizard, that’s what I used too, it reduces the amount of work that I have to do.
During this series, we’ll be creating a number of distinct pieces that we’ll be wrapping in UpdatePanels/Panel combos. The UpdatePanel so that we can AJAX-ify them and then Panels inside the UpdatePanel so that we can hide and reveal the information easily. Let’s start our create user section by adding an UpdatePanel and nesting within it a Panel control that will contain our user creation elements. At the bottom of our page (but before the end tag for our formatEverything Panel), add the following:
<%-- Create User Dialog –%>
<asp:UpdatePanel ID="upnlCreateUser" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlCreateUser" runat="server" style="display: none;"
CssClass="modalPopup">
</asp:Panel>
</ContentTemplate>
</asp:UpdatePanel>
Right off, you might notice a couple things about our Panel. We’re adding a style attribute to it, and the CssClass is being set to ‘modalPopup’. We will be extending this Panel control with a PopupControlExtender allowing it to be a modal popup when the user selects ‘Create User’. The CssClass provides this formatting. We also add a style that hides our element, rather than using the visible property because we want to be able to use the AJAX control extender on it. If we use visible and turn it to false, then the control is not rendered on the server and we will be given an error because the JavaScript has nothing to target (i.e. the control doesn’t exist once it gets to client-side).
I’m again going to use a table to do formatting (oh, I have issues with myself…) and we’ll layout our controls within that table so that our formatting is easily done. Well create a 2x2 table and use a colspan on the first row so that it becomes a header. Then we’ll use the two sides of the bottom row to display the CreateUserWizard in one, and the roles the user can be assigned to on the other side. Add the following table to your code, within our pnlCreateuser Panel:
<asp:Table runat="server" ID="tblCreateUser">
<asp:TableRow>
<asp:TableCell ColumnSpan="2">
<asp:Label ID="lblCreateUserInstructions">
Add a user: Enter the user's information and
select their assigned roles:
</asp:Label>
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell VerticalAlign=”Top”>
</asp:TableCell>
<asp:TableCell VerticalAlign=”Top”>
</asp:TableCell>
</asp:TableRow>
</asp:Table>
Here we create our table, we added a label to the header row, again so we can target it programmatically if we desire to do so. Now we’re ready to add with our CreateUserWizard control. In our first empty TableCell, add the following:
<asp:CreateUserWizard ID="cuwAddUser" runat="server"
DisplayCancelButton="True"
emailRegularExpression="\S+@\S+\.\S+"
CancelButtonType="Link"
FinishCompleteButtonType="Link"
CreateUserButtonType="Link"
LoginCreatedUser="false"
StepPreviousButtonType="Link"
StepPreviousButtonText="">
<WizardSteps>
<asp:CreateUserWizardStep runat="server"
ID=”cuwStep1” Title="Add a new User:" />
<asp:CompleteWizardStep runat="server" />
</WizardSteps>
</asp:CreateUserWizard>
<br />
<asp:CheckBox ID="chkNewUserActive" runat="server"
Checked="true" text="Active User"/>
In our CreateUserWizard, we specify that all our buttons will be links (for continuity’s sake) and that we won’t log in our user after we create them. We could use our CreateUserWizard without specifying anything in regards to the steps, but I wanted to customize the message at the top of the step, and I couldn’t do that without defining the step explicitly. Also we don’t seem to be allowed so skip the CompleteWizardStep, so it’s also included. We won’t be using it, and in fact we’ll probably have to do a workaround so that we can do things our own way (i.e. so we can skip it entirely). Also notice that the CreateWizardStep has an ID assigned. Technically we don’t need to include that, but we will so that we can use it later in the “skip CompleteWizardStep” workaround.
You’ll also notice that a checkbox is included just below our CreateUserWizard. This allows us to create a user and assign their active state all at the same time. We could modify the step’s template to include it, or add another step, but this will work just as well and we can then let the CreateUserWizard do most of the work. The one drawback is that the checkbox will end up below our Create User and Cancel links, but that’s livable in my book.
In the other TableCell, we’ll create a repeater containing roles that we can assign to our new user. Unfortunately the repeater doesn’t have any EmptyDataTemplate like our GridView does so we have to approximate one. We’ll add a label that can double as our instructions and do some handling on backside for it. Add the following between our other set of empty TableCell tags:
<asp:Label ID="lblCreateUserRolesTile" runat="server" Text="Roles" /><br>
<asp:Label id="lblCreateUserRoles" runat="server" Text="Select Roles:" />
<br><br>
<asp:Repeater ID="rptCreateUserRoles" runat="server">
<ItemTemplate>
<asp:CheckBox ID="chkRole" runat="server"
text=”<%# Container.DataItem.ToString() %>” /><br />
</ItemTemplate>
</asp:Repeater>
Here we create two labels, one for a title, and one that we’ll use either for instructions or to notify the user that there are no roles defined to add to their user. The Repeater is pretty simple, just a checkbox in the ItemTemplate. When we access the page, the text property is bound to the Data (the role name).
Finally, we want to add what we need to make this a modal popup dialog. For this, we need to add a ModalPopupExtender. We also need to tie one of it’s parameters to a button. Since we want to control everything from code, we’ll make a “fake” button and make it invisible. Add the following just below our pnlCreateUser panel’s end tag but still within the ContentTemplate for our UpdatePanel:
<asp:Button ID="btnFakeMPECreateUser" runat="server"
Style="display: none;" />
<cc1:ModalPopupExtender ID="mpeCreateUser" runat="server"
BackgroundCssClass="modalBackground"
TargetControlID="btnFakeMPECreateUser"
PopupControlID="pnlCreateUser" >
</cc1:ModalPopupExtender>
We create a fake button and set it’s display to none, again because it has to exist and if we put visible=false, then it will not exist for the AJAX JavaScript to target. The ModalPopupExtender requires a TargetControlID (our fake button that opens the popup) and a PopupControlID (for the panel to use as the dialog). We set a background class so that all our styles can control how the background looks. There are a number of other properties that we didn’t define, if you want to know more about them you can find them on the asp.net site here.
Ok, with our front-end work done, we can get started on the back-end coding. Let’s start by hooking up our Create User link. Since we’re going to change elements on our page a number of times depending on what we are going to display, we’ll create a function that allows us to control the UI all in one place. We’ll create a SetUI subroutine and use it to do our UI shuffling. We’ll create an enumeration for it as well and use that when we call our routine. Add the following to your code behind:
Private Enum SetUIModes
CreateUser
ManageUsers
End Enum
Private Sub SetUI(ByVal modeToChoose As SetUIModes)
Select Case modeToChoose
Case SetUIModes.CreateUser
mpeCreateUser.Show()
Case SetUIModes.ManageUsers
End Select
End Sub
Basically when we create a new user, we just show our ModalPopupExtennder and viola, we have a Popup. We don’t necessarily need to worry about closing the Popup, it tends to close itself pretty easily. Next, we want to call SetUI when we click to create a user. Add the following to your lnkCreateUser_Click event handler:
SetUI(SetUIModes.CreateUser)
Try it out and you’ll see that it works. If you click Cancel, the Popup goes away, and if you click Create user you’ll get validation and the popup doesn’t go away. Next we’ll want to populate our roles list. (You may want to use the WSAT to add a role or two for the time being so that you can test things in this section). We’ll want to define a constant to target our roles data source, much like we did for the users. Add the this global constant to your code:
Private Const ROLES_DATA_SOURCE As String = "ROLES_DATA_SOURCE"
Next, we’ll create a helper routine to bind a repeater to a our roles data source, much like we did with the BindGrid routine above. We’ll may use this again so creating a BindRepeater subroutine makes sense. Add the following helper routine to your code:
Private Sub BindRepeater(ByVal theRepeater As Repeater, _
ByVal dataSourceConstant As String)
theRepeater.DataSource = theSession(dataSourceConstant)
theRepeater.DataBind()
End Sub
You’ll notice that is is basically just like our BindGrid only that it accepts a repeater instead. We pull our data and bind it to grid, not much to it.
Back in our lnkCreateUser_Click event handler, we want to set our data source and then call our BindRepeater. Add the following BEFORE your SetUI statement:
SetDataSource(Roles.GetAllRoles(), ROLES_DATA_SOURCE)
BindRepeater(rptCreateUserRoles, ROLES_DATA_SOURCE)
If rptCreateUserRoles.Items.Count = 0 Then
lblCreateUserRoles.Text = "No roles defined."
End If
chkNewUserActive.Checked = True
Run your app and you’ll see that it works. We create and then bind our data source and then we add a check to see if our data set is empty. If it is, then we want to set our instructions label to indicate that there are no roles defined. We also set the active user checkbox to checked (in case it has been unchecked previously). Now we just need to hook it up so that when the user is created, we assign the roles and stuff. First we’ll create a helper routine that allows us to pass in a repeater and have it update the user’s roles (we’ll use it again later). Add the following helper subroutine to your code:
Private Sub UpdateRoleMembership(ByVal theRepeater As Repeater, _
byVal theUser As String)
If String.IsNullOrEmpty(theUser) Then Exit Sub
For Each rptItem As RepeaterItem In theRepeater.Items
Dim theCheckbox As CheckBox = _
CType(rptItem.FindControl("chkRole"), CheckBox)
If theCheckbox.Checked = True AndAlso _
Roles.IsUserInRole(theUser, theCheckbox.Text) = False Then
Roles.AddUserToRole(theUser, theCheckbox.Text)
End If
If theCheckbox.Checked = False AndAlso _
Roles.IsUserInRole(theUser, theCheckbox.Text) = True Then
Roles.RemoveUserFromRole(theUser, theCheckbox.Text)
End If
Next
End Sub
First we check that the username isn’t invalid (a null string). Then we cycle through the repeater grabbing the checkbox from the RepeaterItem each time around. If the user is already in a role, we don’t want to add it again (not a problem in this context (creating user), but may be other times we use this routine and it would give us an error) so we check that the checkbox is checked AND that the user isn’t already in the role before adding them. The same goes for removing them (again later), if the user is to be removed AND the user is currently in the role, then we remove them.
We’ll call our helper routine from our cuwCreateUser_CreatedUser cuwAddUser_CreatedUser event handler (note, created, not creating), this way we are sure not to add our roles to a user that wasn’t successfully created. Add the following inside your event handler:
Dim cuwTheUser As CreateUserWizard = CType(sender, CreateUserWizard)
UpdateRoleMembership(rptCreateUserRoles, cuwTheUser.UserName)
First we retrieve the CreateUserControl so we can access the elements within it. From it we use the username element so that we can call our UpdateRoleMembership subroutine.
We also want to set the user to active or not active depending on the user selection. You’ll remember that it isn’t part of the CreateUserWizard, but actually outside of it so we have to handle this ourselves. Add the following to your cuwCreateUser_CreatingUser cuwAddUser_CreatingUser event handler:
cuwAddUser.DisableCreatedUser = Not chkNewUserActive.Checked
Notice that we’re negating what was entered before assigning it. DisableCreatedUser is the negative property of our entry.
Now that the user has been entered, we need to update our GridView with the new user. So we reset our SetDataSource and then Bind it again. Add the following to the end of our cuwAddUser_CreatedUser event handler (after the UpdateRoleMembership call):
SetDataSource(Membership.GetAllUsers(), USERS_DATA_SOURCE)
BindGrid(gvManageUsers, USERS_DATA_SOURCE, True)
This simply resets our Data Source to all members, and then rebinds our data. Finally, we want to display a notification to the user that the user was created successfully. We’ll create a panel that we can display as a modal popup and then in our code we’ll set it’s properties and display it. For this we’ll need to flip back to our front end code and add the following at the bottom, but still within our formatEverything Panel:
<%-- Message Dialog --%>
<asp:UpdatePanel ID="upnlMessageDialog" runat="server" ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel id="pnlMessageDialog" runat="server"
CssClass="modalPopup" style="display:none; text-align: center;" >
<asp:Label ID="lblMessageDialog" runat="server" /> <br /> <br />
<asp:Button ID="btnMessageDialogOk" runat="server" Text="OK" />
</asp:Panel>
<asp:Button ID="btnFakeMPEMessageDialog" runat="server"
style="display:none;" />
<cc1:ModalPopupExtender ID="mpeMessageDialog" runat="server"
TargetControlID="btnFakeMPEMessageDialog"
PopupControlID="pnlMessageDialog"
CancelControlID="btnMessageDialogOk"
BackgroundCssClass="modalBackground">
</cc1:ModalPopupExtender>
</ContentTemplate>
</asp:UpdatePanel>
We’re basically just creating a panel with a label and a button. Then we’re adding a fake button for the extender and the extender and configuring it. We added one extra parameter to the extender: CancelControlID. This becomes an automatic way for us to close the modal popup without having to code it. Next, we want to go back to our code-behind and add in functionality to configure and call it.
Let’s create a helper function for setting the message as follows:
Private Sub SetMessageDialog(ByVal theMessage As String)
lblMessageDialog.Text = theMessage
End Sub
Now we just need to call it, so add to the bottom of our cuwAddUser_CreatedUser event handler:
SetMessageDialog("User successfully created.")
SetUI(SetUIModes.MessageDialog)
We also need to implement the appropriate functionality in the SetUI routine. We need to add a case for the Message Dialog. Add ‘MessageDialog’ to your enumeration, and then add the following case to your SetUI’s case statement:
Case SetUIModes.MessageDialog
mpeMessageDialog.Show()
Try your page, it should work: it should successfully create a user (if you successfully enter valid information) and add the user to the appropriate roles. It should also assign the user as active or disabled based on our selection. Cool. Once the user is created, you should get a message letting you know it was successfully created. You may even be able to see our new user in the GridView behind our dialog box if you’re lucky. When we click Ok, our dialog goes and we’re back to the GridView. Go ahead and click to create another user and Oi’ Vey’ what’s that?
It seems that the wonderful automation of our CreateUserWizard has managed to add a message dialog of it’s own. When we finish adding our user, it will display the final WizardStep (CompleteWizardStep). Clicking the continue button may make our dialog close (well, it doesn’t for me), but there’ isn’t anything wired up for it to do when the continue button is clicked. Usually, this leads to another page, in our case we don’t want it to, we want it to stay on our page, but we want to somehow reset the CreateUserWizard so it can be used again, and do it behind the scenes so that the administrator using it doesn’t have to deal with it. Sounds easy right?
Unfortunately, the CreateUserWizard doesn’t have a .Reset or a .Clear method, and there doesn’t seem to be a way to just click continue and have it reset (this is probably due to the control being created for regular people to enter their own information and not for administrators to enter information repeatedly), so we’ll be forced to do it the round about way and push our Wizard back a step and then clear it’s contents. (PS, if anyone can find a better way, I’d like to know about it). So, let’s create another helper subroutine to clear our Grid’s data and set back to the first step:
Private Sub cuwAddUser_Reset(ByVal theWizard As CreateUserWizard, _
ByVal theStep As WizardStepBase)
theWizard.MoveTo(theStep)
theWizard.UserName = ""
theWizard.Email = ""
theWizard.Question = ""
End Sub
We first set the Wizard back to the first step (that’s why we assigned it an ID). Then we clear all the fields that won’t automatically clear themselves when we step back. Now we just need to call it. In my testing, I’ve found that it needs to be in the SetUI’s CreateUser block to be successful (thus it will reset just before we use it). Add the following line just before the mpeCreateUser.show() line:
cuwAddUser_Reset(cuwAddUser, cuwStep1)
We call our Routine and pass in our CreateUserWizard and the OBJECT, not the string for our CreateUserStep. Now run your application and create two users in a row. The confirmation from the CreateUserWizard should no longer be a problem.
Finally, let’s wire up the Delete User functionality. You’ve probably created enough users that you’re ready to start deleting a few of them, so let’s get started.
First we’ll want to create the confirmation dialog for our deletion. This dialog will then kick off the actual deletion of the user, depending on if yes or no was selected. So go back to our front-end markup code and add the following at the bottom:
<%-- Delete User Confirm Dialog --%>
<asp:UpdatePanel ID="upnlDeleteUserConfirm" runat="server"
ChildrenAsTriggers="true">
<ContentTemplate>
<asp:Panel ID="pnlDeleteUserConfirm" runat="server"
Style="display:none; text-align: center;" CssClass="modalPopup">
<asp:Label ID="lblDeleteConfirmUserName"
runat="server" visible="false" />
<asp:Label ID="lblDeleteUserConfirmMessage" runat="server" />
<br /><br />
<asp:Button id="btnDeleteUserYes" runat="server" Text="Yes" />
<asp:Button ID="btnDeleteUserNo" runat="server" Text="No" />
</asp:Panel>
<asp:Button id="btnFakeMPEDeleteUserConfirm" runat="server"
Style="display:none;" />
<cc1:ModalPopupExtender ID="mpeDeleteUserConfirm" runat="server"
TargetControlID="btnFakeMPEDeleteUserConfirm"
PopupControlID="pnlDeleteUserConfirm"
CancelControlID="btnDeleteUserNo"
BackgroundCssClass="modalBackground">
</cc1:ModalPopupExtender>
</ContentTemplate>
</asp:UpdatePanel>
Here we create a dialog containing our confirmation message and two buttons. You may notice that there are two labels present. The one that is hidden, allows us to pass the username to be deleted around, since it has to survive two trips to the server (once when delete is clicked and once when yes is clicked). This will be our means of state preservation. By now, you’re probably getting pretty familiar with the ModalPopupExtender, so I won’t point out much except that our CancelControlID is our No button. Much like our OK button in the message dialog, we can rely on it to close the popup and we don’t even need to clean up.
Let’s jump back to our Code-behind and wire up our events there. First we need to catch the event signifying that our Delete User link has been clicked. Our front-end defines that as gridUsersClick subroutine. We already created a stub for it earlier so that our page would work. However, before we can use it as we’d like to, we need to make some changes to the subroutine’s definition. We need to allow it to receive the normal event handler parameters. Modify your subroutine definition as follows:
Public Sub gridUsersClick(ByVal sender As Object, _
ByVal e As CommandEventArgs)
Now we can add some functionality for deleting users. Add the following to our gridUsersClick subroutine:
If e.CommandName.Equals("DeleteUser") Then
lblDeleteConfirmUserName.Text = CStr(e.CommandArgument)
lblDeleteUserConfirmMessage.Text = "Are you sure you want to delete '" _
& lblDeleteConfirmUserName.Text & "'? This cannot be undone."
SetUI(SetUIModes.DeleteUserConfirm)
End If
We examine our CommandEventArgs for the CommandName and if it is DeleteUser, we setup our confirmation box with the username and message. When we defined our rows link, we defined that the CommandArgument should contain the username, so we’ll pull it from there. We’ll then show the confirmation using the SetUI subroutine. This means that we need to add some to our SetUI subroutine. Add an entry to your Enumeration: ‘DeleteUserConfirm’, then add the following case to our SetUI case statement:
Case SetUIModes.DeleteUserConfirm
mpeDeleteUserConfirm.Show()
Now we just need to add some functionality to our YES button so that when the administrator selects it, our user will be deleted. Add the following to our btnDeleteUserYes_Click event handler:
Membership.DeleteUser(lblDeleteConfirmUserName.Text)
SetDataSource(Membership.GetAllUsers(), USERS_DATA_SOURCE)
BindGrid(gvManageUsers, USERS_DATA_SOURCE, False)
Once again, after we make the changes, we’ll recreate our data source and then rebind our GridView to show the changes. Run your application and test it out. You can now create and delete users at will!
Epilogue
Well, today we’ve added functionality for creating and deleting users. Currently we should have fulfilled all 6 our initial requirements except #3 and #4 (where we defined ALL the functionality). Ok, what that really means is that we’ve only finished two small pieces of our functionality from requirement #3 and haven’t even started #4. However, we do have a working control that displays all the users, filters them and we can successfully create new users and delete existing users. In the next segment, we’ll look at editing an existing user.
Prologue
**Author Note – If you are bewildered by a line (perhaps two) that are red and bold, don’t they that they’re errors, they are corrections/additions. Please see the comments, after the article for comments regarding the highlighted line(s).**
In the last post, we got everything setup so that we can create “A Better ASP.Net Member/Role Management Page”. Today we’ll get started creating the page. If you’ve ever used Microsoft’s Web Site Administration Tool (WSAT), you’ve seen what we’re recreating.
Problem
Today we want to focus on displaying the users in our database and then additionally focus on a creating the framework for managing our users. If you look at the picture to the right, you’ll see an example of what we want to create today. If you are familiar with WSAT, you’ll notice that what we’re going to implement is a little different than the WSAT, I’ve removed the Edit Roles link. This functionality will be available in the Edit User location once we’ve implemented that piece. For now, we’ll setup the grid and test the functionality of the following features: Search for users and our GridView (retrieving and displaying users and paging in our GridView).
Solution
Before we begin, I have one piece of setup that I neglected in the last post that I want to take care of here quickly. If you are familiar with using AJAX at all, you know that we’ll need to have a ScriptManager on our page so that we can use AJAX functionality. If you notice, in our AJAX Control Toolkit we have a ToolkitScriptManager control that we can add to our page. We’ll use this one rather than the ScriptManager in the AJAX Extensions that comes with VS.
We have 2 possible locations to put this. One is in our page (that one that holds our UserControl) and the other location would in the actual UserControl. Each has it’s plusses and minuses. If we put the ScriptManager in the page (and especially into our Master Page), we’ll have AJAX capability in ALL our pages, HOWEVER, as a result, our control will not be self contained (since we have to perform outside tasks for it to work properly). If we add the ScriptManager to our UserControl, we’ll have the the AJAX functionality baked in, and be self-contained, but we’ll run the risk of having two ScriptManagers on a page if our page (or Master Page) has a ScriptManager on it. This would give us an error since we’re only allowed to have one ScriptManager per page. Since the site I’m writing this control for has the ScriptManager in our MasterPage, we’ll add the ScriptManager to our Page rather than to our UserControl. You may feel differently in your environment, but putting it in our Master Page was the best idea in ours.
Jump over to your TestUserAdminControlPage.aspx and we’ll add the ScriptManager there. Look in your AJAX Control Toolkit, and find the ToolkitScriptManager control. Drag this out into the body of our page, probably right below our form tag. It should add an entry as such:
<cc1:ToolkitScriptManager ID="ToolkitScriptManager1" runat="server">
</cc1:ToolkitScriptManager>
With setup completed, let’s get started. Jump back over to your UserAdminControl.aspx user control and switch to Source view (we’ll be editing there since we’ll be creating user TemplateFields etc.). We want to create a means of applying styles to the entirety of page, so we can consistently apply formatting (such as fonts across all the elements in our control). We’ll do this by creating a panel control and adding it to our UserControl, directly below the end </style> tab. We’ll name it pnlFormatEverything and define it as such:
<asp:Panel ID="pnlFormatEverything" runat="server" CssClass="bodyText">
</asp:Panel>
We will put EVERYTHING we do within these tags, so that our bodyText formatting will cascade down to everything. Next, we want to put an UpdatePanel that will contain our GridView. If you aren’t familiar with the UpdatePanel, it is a means of AJAX-ifying the content that resides within the panel. We will be using a number of the throughout this series, to AJAX-ify various pieces. Drag out an update panel (from the AJAX Extensions that come with VS, not the Toolkit) and put it just below the opening tag for our pnlFormatEverything panel. Define it as such:
<!-- Main User Grid -->
<asp:UpdatePanel Runat="server" ID="upnlMainUserGrid"
ChildrenAsTriggers="true">
<ContentTemplate>
</ContentTemplate>
</asp:UpdatePanel>
As you may notice, the update panel has a ContentTemplate section, this is where our AJAX-ified content will go. Inside our ContentTemplate, we’ll want to create another panel control that we can make visible or invisible depending on what is being displayed. We’ll use it in the future, for right now, we just want to create it. Inside that panel, we'll create two labels, one for a title (if desired) and one for directions. We’ll use labels so that the contents can be changed programmatically if desired. Add the following inside your ContentTemplate:
<asp:Panel ID="pnlManageUsers" runat="server">
<asp:Label ID="lblManageUsersTitle" runat="server" Text="" /><br />
<asp:Label ID="lblManageUsersInstructions" runat="server" Text="" />
<br /><br />
</asp:Panel>
I left the text on these blank, you’ll want to design our own text. While I put some text in mine, I left it out for brevity’s sake. Next, we’ll create the repeater that we’ll use to filter/search our user GridView. Typically I don’t like to use Tables as layout tools, but in this case I am going to make an exception. We’ll create a table that will hold all the search for users information. It will have two rows, one that’s a header and one that’s contains a repeater that has all our letters in it. Again, we’ll create our header as a label so that we can access it programmatically, if desired. In our other Row, we’ll add our Search for fields and the repeater as follows:
<asp:Table runat="server" ID="tblSearchUsers" CssClass="tableBorders" >
<asp:TableRow>
<asp:TableCell CssClass="tableHeadersNTitles">
<asp:Literal ID="lblSearchUsersHeader" runat="server"
Text="Search for Users" />
</asp:TableCell>
</asp:TableRow>
<asp:TableRow>
<asp:TableCell CssClass="tableBody">
<asp:Label ID="lblSearchUsersByDropDown" runat="server"
Text="Search by: " />
<asp:DropDownList ID="ddlSearchUsersBy" runat="server">
<asp:ListItem Text="Username" Selected="True" />
<asp:ListItem Text="Email" />
</asp:DropDownList>
<asp:Label ID="lblSearchUsersFor" runat="server" Text=" for: " />
<asp:TextBox ID="txtSearchUsersFor" runat="server" />
<cc1:TextBoxWatermarkExtender ID="tweSearchUsersFor"
TargetControlID="txtSearchUsersFor"
WatermarkCssClass="watermarked"
WatermarkText="Enter criteria" runat="server" />
<asp:Button ID="btnSearchUsers" runat="server" Text="Search" />
<br />
<asp:Label ID="lblSearchUsersWildcardsOk" runat="server" >
Wildcard characters * and ? are permitted.
</asp:Label> <br />
<asp:Repeater ID="rptSearchUsersAlphabet" runat="server"
OnItemCommand="RetrieveLetter">
<ItemTemplate>
<asp:LinkButton ID="lnkSearchUsersLetter" runat="server"
CommandName="DisplayUsers"
CommandArgument="<%#Container.DataItem%>"
Text="<%#Container.DataItem%>" />
</ItemTemplate>
</asp:Repeater>
</asp:TableCell>
</asp:TableRow>
</asp:Table>
<asp:UpdateProgress runat="server" ID="upLoading">
<ProgressTemplate>Loading...</ProgressTemplate>
</asp:UpdateProgress>
<br />
Ok, so we added quite a bit of code there, here’s what’s going on. We created a table with 2 rows, the first is just a header. The second row is where all the work goes on. We create a search line, it has a label a DropDown list (to select to search user by email or username) another label a text box to enter a search term and a button to kick off the search. We’ll have to wire up the button, but we’ll do that in a little while. Also of note, we add a TextBoxWatermarkExtender control. This is an AJAX control will extend our textbox (note the TargetControlID parameter) and enter the text in the WatermarkText parameter into the textbox. The control will take care of making it disappear when we click in the TextBox and reappear if the TextBox loses focus while the Textbox is empty (This control extender is one of the main reasons I wanted to AJAX-ify the page). We then add a little instruction letting people know that they can use wildcards in their search.
Next, we add a repeater and define it’s properties. A few items of note in regard to the repeater: we define an ItemTemplate (as you must with a repeater control) and within it we add a LinkButton. To this LinkButton, we add a CommandArgument and a CommandName. The CommandName allows us to determine what command was issued when we catch the event on backside. This can use this to uniquely define our LinkButton click from other onCommand events. We also define a CommandArgument that we’ll use to pass the actual letter that was clicked so that we can filter based on the selected letter. Both the text property and the CommandArgument property will be set to a letter when we bind our data source later (which will pretty much just be the alphabet).
Finally, just outside our table, we add an UpdateProgress control. This will notify users while we are doing an AJAX post back that something is happening. This is a pretty low key notification, but it’ll suit our purposes (likely, everything will happen so quickly you won’t see it anyhow…). You can probably add a URL via properties to a ‘loading’ graphic.
We’ll need to wire up the onCommand event and also need to create our data source and bind the repeater, we’ll get to that in a moment, first let’s finish up the front-end work for our user GridView. Let’s add a GridView to bind our users to. We’ll create a GridView and add some formatting. Once we’ve figured out our formatting, we’ll go back and insert some columns between the column tags. Create a GridView as follows:
<asp:GridView ID="gvManageUsers" runat="server"
CellPadding="3"
GridLines="Horizontal"
CssClass="userGridView"
AutoGenerateColumns="false"
AllowPaging="true" PageSize="7"
UseAccessibleHeader="true">
<RowStyle CssClass="gridRowStyle" />
<AlternatingRowStyle CssClass="gridAlternatingRowStyle" />
<PagerStyle CssClass="gridPagerStyle" />
<PagerSettings Mode="Numeric" />
<HeaderStyle CssClass="tableHeadersNTitles" Font-Bold="true" />
<Columns>
</Columns>
</asp:GridView>
Really all this does is create and configure our Gridview and apply some formatting. Points of note would be the configuration settings for the GridView. You may notice that I have CellPadding and a CssClass assigned. I’ve had a terrible time trying to add a CellPadding trough CSS so I cheated and added this a setting. Also, notice that GridLines are set to horizontal, that way we get cleanly defined rows, but not delineated cells. We’re no