We'll get right into the implementation because we've already covered the basic material and our objectives in the "Design" section of this chapter. Now we'll put all the pieces together to create the pages and the supporting code to make them work. These are the steps used to tackle our solution:
Define all the settings required for membership, roles, and profiles in web.config.
Create the login box on the master page, and the "access denied" page. To test the login process before creating the registration page, we can easily create a user account from the ASP.NET Web Administration Tool.
Create the registration and profiling page.
Create the password recovery page.
Create the page to change the current password and all the profile information.
Design profiles to save the user's favorite theme, and handle the migration from an anonymous user to an authenticated user so we won't lose his theme preference.
Create the administration pages to display all users, as well as edit and delete them.
Following is a partial snippet of the web.config file (located in the site's root folder) used to configure the authentication type, membership, role manager, profile, and sitemap provider (in this order):
<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0"> <!-- other settings... --> <system.web> <authentication mode="Forms"> <forms cookieless="AutoDetect" loginUrl="~/AccessDenied.aspx" name="TBHFORMAUTH" /> </authentication> <membership defaultProvider="TBH_MembershipProvider" userIsOnlineTimeWindow="15"> <providers> <add name="TBH_MembershipProvider" connectionStringName="LocalSqlServer" applicationName="/" enablePasswordRetrieval="true" enablePasswordReset="true" requiresQuestionAndAnswer="true" requiresUniqueEmail="true" passwordFormat="Encrypted" maxInvalidPasswordAttempts="5" passwordAttemptWindow="10" minRequiredPasswordLength="5" minRequiredNonalphanumericCharacters="0" type="System.Web.Security.SqlMembershipProvider, System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" /> </providers> </membership> <roleManager enabled="true" cacheRolesInCookie="true" cookieName="TBHROLES" defaultProvider="TBH_RoleProvider"> <providers> <add name="TBH_RoleProvider" connectionStringName="LocalSqlServer" applicationName="/" type="System.Web.Security.SqlRoleProvider, System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" /> </providers> </roleManager> <anonymousIdentification cookieless="AutoDetect" enabled="true"/> <profile defaultProvider="TBH_ProfileProvider"> <providers> <add name="TBH_ProfileProvider" connectionStringName="LocalSqlServer" applicationName="/" type="System.Web.Profile.SqlProfileProvider, System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" /> </providers> <properties> <add name="FirstName" type="String" /> <add name="LastName" type="String" /> <add name="Gender" type="String" /> <add name="BirthDate" type="DateTime" /> <add name="Occupation" type="String" /> <add name="Website" type="String" /> <group name="Address"> <add name="Street" type="String" /> <add name="PostalCode" type="String" /> <add name="City" type="String" /> <add name="State" type="String" /> <add name="Country" type="String" /> </group> <group name="Contacts"> <add name="Phone" type="String" /> <add name="Fax" type="String" /> </group> <group name="Preferences"> <add name="Theme" type="String" allowAnonymous="true" /> <add name="Culture" type="String" defaultValue="en-US" /> <add name="Newsletter" type="MB.TheBeerHouse.BLL.Newsletters.SubscriptionType" /> </group> </properties> </profile> <machineKey validationKey="287C5D125D6B7E7223E1F719E3D58D17BB967703017E1BBE28618FAC6C4501E910C7 E59800B5D4C2EDD5B0ED98874A3E952D60BAF260D9D374A74C76CB741803" decryptionKey="5C1D8BD9DF3E1B4E1D01132F234266616E0D5EF772FE80AB" validation="SHA1"/> <siteMap defaultProvider="TBH_SiteMapProvider" enabled="true"> <providers> <add name="TBH_SiteMapProvider" type="System.Web.XmlSiteMapProvider" securityTrimmingEnabled="true" siteMapFile="web.sitemap" /> </providers> </siteMap> </system.web> <location path="EditProfile.aspx"> <system.web> <authorization> <deny users="?" /> <allow users="*" /> </authorization> </system.web> </location> <system.net> <mailSettings> <smtp deliveryMethod="Network"> <network defaultCredentials="true" host="vsnetbeta2" port="25" from="mbellinaso@wrox.com"></network> </smtp> </mailSettings> </system.net> </configuration>
As you can see, a provider is defined and configured for all modules that support this pattern. I specified the provider settings even though they are often the same as the default providers found in machine.config.default, because I can't be sure whether the defaults will always be the same in future ASP.NET releases, and I like to have this information handy in case I might want to make further changes someday. To create these settings I copied them from machine.config.default, and then I made a few tweaks as needed.
I defined a Newsletter profile property as type MB.TheBeerHouse.BLL.Newsletters.Subscription Type, which is an enumeration defined in the Enum.cs file located under App_Code/BLL/ Newsletter:
namespace MB.TheBeerHouse.BLL.Newsletters { public enum SubscriptionType : int { None = 0, PlainText = 1, Html } }
In order to configure the cryptographic keys, we need to set the validationKey and decryptionKey attributes of the machineKey element. Because we are configuring the membership module to encrypt passwords, we can't leave them set at AutoGenerate, which is the default. You can find some handy utilities on the Internet that will help you set these values. You can check the following Microsoft Knowledge Base article for more information: http://support.microsoft.com/kb/313091/. This article shows how to implement a class that makes use of the cryptographic classes and services to create values for these keys. Alternately, if you want an easier way to create these keys, check out this online tool: www.aspnetresources.com/tools/keycreator.aspx.
I want to reiterate a point I made earlier in this chapter: If you'll be deploying your application to a web farm (more than one web server configured to distribute the load between the servers), then you need to specify the same machine keys for each server. In addition to password encryption, these keys are also used for session state. By synchronizing these keys with all your servers, you ensure that the same encryption will be used on each server. This is essential if there's a chance that a different server might be used to process the next posting of a page.
In Chapter 2, when we created the master page (the template.master file), we defined a <div> container on the top-right corner that we left blank for future use. A < div> declares an area on a web page called a division — it's typically used as a container for other content. Now it's time to fill that <div> with the code for the login box. ASP.NET typically uses customizable templates to control the visual rendering of standard controls, and this template concept has been expanded in version 2.0 to include most of the new controls. In this section we will customize the default user interface of the login controls by providing our own template. Using custom templates offers the following advantages:
You have full control over the appearance of the produced output, and you can change many aspects of the behavior. For example, our custom template can be used with validator controls, and you can set their SetFocusOnError property to true (this defaults to false in the default template). This property is new to ASP.NET 2.0 and specifies whether the validator will give the focus to the control it validates if the client-side validation fails. This is desirable in our case because we want the focus to go to the first invalid control after the user clicks the Submit button if some controls have invalid input values.
If you don't redefine the TextBox controls, the SetInputControlsHighlight method we developed in Chapter 2 will not find them, and thus the textboxes will not get the special highlight behavior that gives users a visual cue as to which TextBox currently has the focus.
Here's the complete code that uses a LoginView to display a login box. This login box contains links to register a new account or to recover a forgotten password when the user is anonymous, or it will contain a welcome message, a logout link, and a link to the EditProfile page if the user is currently logged in:
<div id="loginbox"> <asp:LoginView ID="LoginView1" runat="server"> <AnonymousTemplate> <asp:Login ID="Login" runat="server" Width="100%" FailureAction="RedirectToLoginPage"> <LayoutTemplate> <table border="0" cellpadding="0" cellspacing="0" width="100%"> <tr> <td width="60px">Username:</td> <td><asp:TextBox id="UserName" runat="server" Width="95%" /></td> <td width="5px" align="right"> <asp:RequiredFieldValidator ID="valRequireUserName" runat="server" SetFocusOnError="true" Text="*" ControlToValidate="UserName" ValidationGroup="Login" /> </td> </tr> <tr> <td>Password:</td> <td><asp:TextBox ID="Password" runat="server" TextMode="Password" Width="95%" /></td> <td width="5px" align="right"> <asp:RequiredFieldValidator ID="valRequirePassword" runat="server" SetFocusOnError="true" Text="*" ControlToValidate="Password" ValidationGroup="Login" /> </td> </tr> </table> <table border="0" cellpadding="0" cellspacing="0" width="100%"> <tr> <td><asp:CheckBox ID="RememberMe" runat="server" Text="Remember me"></asp:Checkbox></td> <td align="right"> <asp:ImageButton ID="Submit" runat="server" CommandName="Login"ImageUrl="~/images/go.gif" ValidationGroup="Login" /> </td> <td width="5px" align="right"> </td> </tr> </table> <div style="border-top: solid 1px black; margin-top: 2px"> <asp:HyperLink ID="lnkRegister" runat="server" NavigateUrl="~/Register.aspx">Create new account </asp:HyperLink><br /> <asp:HyperLink ID="lnkPasswordRecovery" runat="server" NavigateUrl="~/PasswordRecovery.aspx">I forgot my password </asp:HyperLink> </div> </LayoutTemplate> </asp:Login> </AnonymousTemplate> <LoggedInTemplate> <div id="welcomebox"> <asp:LoginName ID="LoginName1" runat="server" FormatString="Hello {0}!" /><br /> <small> <font face="Webdings">4</font> <asp:HyperLink ID="lnkProfile" runat="server" Text="Edit Profile" NavigateUrl="~/EditProfile.aspx" /> <font face="Webdings">3</font><br /> <font face="Webdings">4</font> <asp:LoginStatus ID="LoginStatus1" Runat="server" /> <font face="Webdings">3</font> </small> </div> </LoggedInTemplate> </asp:LoginView> </div>
Absolutely no code is needed in the master page's code-behind files. In fact, because we used the right IDs for the textboxes and other controls in the template sections of the Login control, it will continue working autonomously as if it were using the default UI. To test the control, first create a new user through the ASP.NET Web Site Configuration Tool described earlier, and then try to log in. In Figure 4-14 you can see what the home page looks like from an anonymous user's and an authenticated user's point of view.
Observe the login box in the first window (only displayed for anonymous users), and the new greeting message and links in the second window that are displayed after the user logs in. Also, note that an Admin link is visible on the second browser's menu bar. That Admin link only appears for users who have been assigned the Administrators role. The web.sitemap file is used to generate the menu, and the item representing the Admin link was modified by adding the roles attribute, which was set to Administrators:
<siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" > <siteMapNode title="Home" url="~/Default.aspx"> <!-- other items --> <siteMapNode title="Admin" url="~/Admin/Default.aspx" roles="Administrators" /> </siteMapNode> </siteMap>
Of course, you can test the sitemap-controlled menu by assigning the Administrators role to your sample user. You can even do this role assignment from the online configuration application!
If you look a few pages back, you'll see that the loginUrl attribute of the web.config's <forms> is set to AccessDenied.aspx. As its name clearly suggests, this is the URL of the page we want to redirect control to when the user tries to access a protected page to which he doesn't have permission. In many cases you would place the login box in this page, hence the attribute name (loginUrl). In our case, however, we have a site that lets anonymous users access many different pages, and we only require the user to log in to gain access to a small number of pages, so we want to make sure the login box is visible from any page when the user is anonymous. The login box invites the user to log in if they already have an account, or to register if they don't have one. This AccessDenied page is also loaded when a user tries to log in but gives invalid credentials, or when they are already logged in but they don't have a role required by the page they requested. Therefore, the page has three possible messages, and the following code uses three labels for them:
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="AccessDenied.aspx.cs" Inherits="AccessDenied" Title="The Beer House - Access Denied" MasterPageFile="~/Template.master" %> <asp:Content ID="MainContent" ContentPlaceHolderID="MainContent" Runat="Server"> <asp:Image ID="imgLock" runat="server" ImageUrl="~/images/lock.gif" ImageAlign="left" /> <asp:Label runat="server" ID="lblLoginRequired" Font-Bold="true"> You must be a registered user to access this page. If you already have an account, please login with your credentials in the box on the upper-right corner. Otherwise <a href="Register.aspx">click here</a> to register now for free. </asp:Label> <asp:Label runat="server" ID="lblInsufficientPermissions" Font-Bold="true"> Sorry, the account you are logged with does not have the permissions required to access this page. </asp:Label> <asp:Label runat="server" ID="lblInvalidCredentials" Font-Bold="true"> The submitted credentials are not valid. Please check they are correct and try again. If you forgot your password, <a href="PasswordRecovery.aspx">click here</a> to recover it. </asp:Label> </asp:Content>
The Page_Load event handler in the code-behind file contains the logic for showing the proper label and hiding the other two. You need to do some tests to determine which of the three cases applies:
If the querystring contains a loginfailure parameter set to 1, it means that the user tried to log in but the submitted credentials were not recognized.
If the user is not authenticated and there is no loginfailure parameter on the querystring, it means that the user tried to access a page that is not available to anonymous users.
If the current user is already authenticated and this page is loaded anyway, it means the user does not have sufficient permission (read "does not belong to a required role") to access the requested page.
Here is how to translate this description to code:
public partial class AccessDenied : BasePage { protected void Page_Load(object sender, EventArgs e) { lblInsufficientPermissions.Visible = this.User.Identity.IsAuthenticated; lblLoginRequired.Visible = (!this.User.Identity.IsAuthenticated && string.IsNullOrEmpty(this.Request.QueryString["loginfailure"])); lblInvalidCredentials.Visible = ( this.Request.QueryString["loginfailure"] != null && this.Request.QueryString["loginfailure"] == "1"); } }
Figure 4-15 is a screenshot of the first situation — note the message by the image of the padlock.
The user interface and the logic required to show and update a user's profile is contained in a user control named UserProfile.ascx and placed under the Controls folder. Profile properties can be edited in both the registration page and in the page users access to change their profile, so it makes sense to put this code in a user control that can easily be reused in multiple places. The user interface part consists of simple HTML code to layout a number of server-side controls (textboxes and drop-down lists) that will show users their profile properties and let them edit those properties:
<%@ Control Language="C#" AutoEventWireup="true" CodeFile="UserProfile.ascx.cs" Inherits="UserProfile" %> <div class="sectionsubtitle">Site preferences</div> <p></p> <table cellpadding="2"> <tr> <td width="130" class="fieldname">Newsletter:</td> <td width="300"> <asp:DropDownList runat="server" ID="ddlSubscriptions"> <asp:ListItem Text="No subscription" Value="None" Selected="true" /> <asp:ListItem Text="Subscribe to plain-text version" Value="PlainText" /> <asp:ListItem Text="Subscribe to HTML version" Value="Html" /> </asp:DropDownList> </td> </tr> <tr> <td class="fieldname">Language:</td> <td> <asp:DropDownList runat="server" ID="ddlLanguages"> <asp:ListItem Text="English" Value="en-US" Selected="true" /> <asp:ListItem Text="Italian" Value="it-IT" /> </asp:DropDownList> </td> </tr> </table> <p></p> <div class="sectionsubtitle">Personal details</div> <p></p> <table cellpadding="2"> <tr> <td width="130" class="fieldname">First name:</td> <td width="300"> <asp:TextBox ID="txtFirstName" runat="server" Width="99%"></asp:TextBox> </td> </tr> <tr> <td class="fieldname">Last name:</td> <td> <asp:TextBox ID="txtLastName" runat="server" Width="99%" /> </td> </tr> <tr> <td class="fieldname">Gender:</td> <td> <asp:DropDownList runat="server" ID="ddlGenders"> <asp:ListItem Text="Please select one..." Value="" Selected="True" /> <asp:ListItem Text="Male" Value="M" /> <asp:ListItem Text="Female" Value="F" /> </asp:DropDownList> </td> </tr> <tr> <td class="fieldname">Birth date:</td> <td> <asp:TextBox ID="txtBirthDate" runat="server" Width="99%"></asp:TextBox> <asp:CompareValidator runat="server" ID="valBirthDateFormat" ControlToValidate="txtBirthDate" SetFocusOnError="true" Display="Dynamic" Operator="DataTypeCheck" Type="Date" ErrorMessage="The format of the birth date is not valid." ValidationGroup="EditProfile"> <br />The format of the birth date is not valid. </asp:CompareValidator> </td> </tr> <tr> <td class="fieldname">Occupation:</td> <td> <asp:DropDownList ID="ddlOccupations" runat="server" Width="99%"> <asp:ListItem Text="Please select one..." Value="" Selected="True" /> <asp:ListItem Text="Academic" /> <asp:ListItem Text="Accountant" /> <asp:ListItem Text="Actor" /> <asp:ListItem Text="Architect" /> <asp:ListItem Text="Artist" /> <asp:ListItem Text="Business Manager" /> <%-- other options... --%> <asp:ListItem Text="Other" /> </asp:DropDownList> </td> </tr> <tr> <td class="fieldname">Website:</td> <td> <asp:TextBox ID="txtWebsite" runat="server" Width="99%" /> </td> </tr> </table> <p></p> <div class="sectionsubtitle">Address</div> <p></p> <table cellpadding="2"> <tr> <td width="130" class="fieldname">Street:</td> <td width="300"> <asp:TextBox runat="server" ID="txtStreet" Width="99%" /> </td> </tr> <tr> <td class="fieldname">City:</td> <td><asp:TextBox runat="server" ID="txtCity" Width="99%" /></td> </tr> <tr> <td class="fieldname">Zip / Postal code:</td> <td><asp:TextBox runat="server" ID="txtPostalCode" Width="99%" /></td> </tr> <tr> <td class="fieldname">State / Region:</td> <td><asp:TextBox runat="server" ID="txtState" Width="99%" /></td> </tr> <tr> <td class="fieldname">Country:</td> <td> <asp:DropDownList ID="ddlCountries" runat="server" AppendDataBoundItems="True" Width="99%"> <asp:ListItem Text="Please select one..."Value="" Selected="True" /> </asp:DropDownList> </td> </tr> </table> <p></p> <div class="sectionsubtitle">Other contacts</div> <p></p> <table cellpadding="2"> <tr> <td width="130" class="fieldname">Phone:</td> <td width="300"><asp:TextBox runat="server" ID="txtPhone" Width="99%" /></td> </tr> <tr> <td class="fieldname">Fax:</td> <td><asp:TextBox runat="server" ID="txtFax" Width="99%" /></td> </tr> </table>
As you see, there is a DropDownList control that enables users to select their country. The Select an Option item is selected by default and it serves to give users instructions about what they need to do. You may need that country list in other places, and also inside business components, so instead of hardcoding it into this control, the countries are passed as a string array by a helper method. This way, it can easily bind the country list to the drop-down lists, but it can also be used in other ways. Here's the code for the helper method:
public static class Helpers { private static string[] _countries = new string[] { "Afghanistan", "Albania", "Algeria", "American Samoa", "Andorra", "Angola", "Anguilla", "Antarctica", "Antigua And Barbuda", "Argentina", "Armenia", "Aruba", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bermuda", "Bhutan", /* add here all the other countries */ }; /// <summary> /// Returns an array with all countries /// </summary> public static StringCollection GetCountries() { StringCollection countries = new StringCollection(); countries.AddRange(_countries); return countries; } // other methods... }
The DropDownList control has the AppendDataBoundItems property set to true. This is a new property of ASP.NET 2.0 that allows you to specify whether the values added to the control via data-binding will be added to those defined at design time, or will overwrite them as is done with ASP.NET 1.x. Also note that the user control has no Submit button, as this will be provided by the hosting page.
Because this control will be used in the administration area to read and edit the profile for any user, it needs a public property that stores the name of the user for whom you are querying. In ASP.NET 1.x, the typical way to make a property value persistent across postbacks is to save it into the control's ViewState collection, so that it is serialized into a blob of base-64 encoded text, together with all the other property values, and saved into the __VIEWSTATE HTML hidden field. This is better than session state because it doesn't use server resources to save temporary values because the values are saved as part of the overall page. The user won't see the hidden values, but they are there in the user's browser and he'll post them back to the server along with the other form data each time he does a postback. The problem with view state is that it can be disabled, by setting the page's or control's EnableViewState property to False. Disabling this feature is a common way to minimize the volume of data passed back and forth across the network. Unfortunately, controls that use view state to store values will not work correctly if view state is disabled on any particular host page. To solve this issue, ASP.NET 2.0 introduces a new type of state called control state, which is a similar to view state, except that it's only related to a particular control and it can't be disabled. Values that a control wants to save across postbacks can be saved in the control state, while values that are not strictly necessary can go into the view state. In reality, both categories of values will be serialized and saved into the same single __VIEWSTATE field, but the internal structure makes a difference between them. With the view state, you would just read and write the property value from and to the control's ViewState object from inside the property's accessor functions. In order to use control state you need to do the following things:
Define the property so that it uses a private field to store the value.
Call the parent page's RegisterRequiresControlState method, from the control's Init event handler. This notifies the page that the control needs to store some control state information.
Override the control's SaveControlState method to create and return an array of objects you want to save in the control state. The array values consist of the property values you need to be persisted, plus the base class' control state (this always goes into the array's first slot).
Override the LoadControlState method to unpack the input array of objects and initialize your properties' private fields with the values read from the array.
Following is the complete code needed to define and persist a control's UserName property:
public partial class UserProfile : System.Web.UI.UserControl { private string _userName = ""; public string UserName { get { return _userName; } set { _userName = value; } } protected void Page_Init(object sender, EventArgs e) { this.Page.RegisterRequiresControlState(this); } protected override void LoadControlState(object savedState) { object[] ctlState = (object[])savedState; base.LoadControlState(ctlState[0]); _userName = (string)ctlState[1]; } protected override object SaveControlState() { object[] ctlState = new object[2]; ctlState[0] = base.SaveControlState(); ctlState[1] = _userName; return ctlState; } }
This is somewhat more complicated than the older method of using the host page's view state, but you gain the advantage of independence from that page's configuration. You have to weigh the added complexity against the needs of your application. If you'll have a large application with many pages, it is probably wise to use control state because you can't be sure if one of the host pages might have view state disabled (in a large system it's almost guaranteed that some pages will have it disabled). Also, if your controls might be used by other applications within your company, or even other companies, you should definitely use control state to give you the added peace of mind to know that your controls will always work.
Now you can write some code for handling user profiles within a control. In the control's code-behind, you should handle the Load event to first bind the countries array to the proper DropDownList control, and then load the specified user's profile so you can populate the various input controls with profile values. If no username is specified, the current user's profile will be loaded:
protected void Page_Load(object sender, EventArgs e) { if (!this.IsPostBack) { ddlCountries.DataSource = Helpers.GetCountries(); ddlCountries.DataBind(); // if the UserName property contains an emtpy string, retrieve the profile // for the current user, otherwise for the specified user ProfileCommon profile = this.Profile; if (this.UserName.Length > 0) profile = this.Profile.GetProfile(this.UserName); ddlSubscriptions.SelectedValue = profile.Preferences.Newsletter.ToString(); ddlLanguages.SelectedValue = profile.Preferences.Culture; txtFirstName.Text = profile.FirstName; txtLastName.Text = profile.LastName; ddlGenders.SelectedValue = profile.Gender; if (profile.BirthDate != DateTime.MinValue) txtBirthDate.Text = profile.BirthDate.ToShortDateString(); ddlOccupations.SelectedValue = profile.Occupation; txtWebsite.Text = profile.Website; txtStreet.Text = profile.Address.Street; txtCity.Text = profile.Address.City; txtPostalCode.Text = profile.Address.PostalCode; txtState.Text = profile.Address.State; ddlCountries.SelectedValue = profile.Address.Country; txtPhone.Text = profile.Contacts.Phone; txtFax.Text = profile.Contacts.Fax; } }
This control doesn't have a Submit button to initiate saving profile values, so create a public method named SaveProfile that the host page will call when needed:
public void SaveProfile() { // if the UserName property contains an emtpy string, save the current user's // profile, othwerwise save the profile for the specified user ProfileCommon profile = this.Profile; if (this.UserName.Length > 0) profile = this.Profile.GetProfile(this.UserName); profile.Preferences.Newsletter = (SubscriptionType)Enum.Parse( typeof(SubscriptionType), ddlSubscriptions.SelectedValue); profile.Preferences.Culture = ddlLanguages.SelectedValue; profile.FirstName = txtFirstName.Text; profile.LastName = txtLastName.Text; profile.Gender = ddlGenders.SelectedValue; if (txtBirthDate.Text.Trim().Length > 0) profile.BirthDate = DateTime.Parse(txtBirthDate.Text); profile.Occupation = ddlOccupations.SelectedValue; profile.Website = txtWebsite.Text; profile.Address.Street = txtStreet.Text; profile.Address.City = txtCity.Text; profile.Address.PostalCode = txtPostalCode.Text; profile.Address.State = txtState.Text; profile.Address.Country = ddlCountries.SelectedValue; profile.Contacts.Phone = txtPhone.Text; profile.Contacts.Fax = txtFax.Text; profile.Save(); }
Users can create an account for themselves through the Register.aspx page that is linked just below the login box. This page uses the CreateUserWizard control described earlier. The first step is to create the account; the user interface for this is implemented by our custom template. The second step allows the user to fill in some profile settings, and uses the UserProfile control just developed above. The registration code that follows is pretty long, but it should be easy to follow without further comments:
<%@ Page Language="C#" MasterPageFile="~/Template.master" AutoEventWireup="true" CodeFile="Register.aspx.cs" Inherits="Register" Title="The Beer House - Register" %> <%@ Register Src="Controls/UserProfile.ascx" TagName="UserProfile" TagPrefix="mb" %> <asp:Content ID="MainContent" ContentPlaceHolderID="MainContent" runat="Server"> <asp:CreateUserWizard runat="server" ID="CreateUserWizard1" AutoGeneratePassword="False" ContinueDestinationPageUrl="~/Default.aspx" FinishDestinationPageUrl="~/Default.aspx" OnFinishButtonClick="CreateUserWizard1_FinishButtonClick"> <WizardSteps> <asp:CreateUserWizardStep runat="server"> <ContentTemplate> <div class="sectiontitle">Create your new account</div> <p></p> <table cellpadding="2"> <tr> <td width="120" class="fieldname">Username:</td> <td width="300"> <asp:TextBox runat="server" ID="UserName" Width="100%" /> </td> <td> <asp:RequiredFieldValidator ID="valRequireUserName" runat="server" ControlToValidate="UserName" SetFocusOnError="true" Display="Dynamic" ErrorMessage="Username is required." ValidationGroup="CreateUserWizard1">* </asp:RequiredFieldValidator> </td> </tr> <tr> <td class="fieldname">Password:</td> <td> <asp:TextBox runat="server" ID="Password" TextMode="Password" Width="100%" /> </td> <td> <asp:RequiredFieldValidator ID="valRequirePassword" runat="server" ControlToValidate="Password" SetFocusOnError="true" Display="Dynamic" ErrorMessage="Password is required." ValidationGroup="CreateUserWizard1">* </asp:RequiredFieldValidator> <asp:RegularExpressionValidator ID="valPasswordLength" runat="server" ControlToValidate="Password" SetFocusOnError="true" Display="Dynamic" ValidationExpression="\w{5,}" ErrorMessage="Password must be at least 5 characters long." ValidationGroup="CreateUserWizard1">* </asp:RegularExpressionValidator> </td> </tr> <tr> <td class="fieldname">Confirm password:</td> <td> <asp:TextBox runat="server" ID="ConfirmPassword" TextMode="Password"Width="100%" /> </td> <td> <asp:RequiredFieldValidator ID="valRequireConfirmPassword" runat="server" ControlToValidate="ConfirmPassword" SetFocusOnError="true" Display="Dynamic" ErrorMessage="Confirm Password is required." ValidationGroup="CreateUserWizard1">* </asp:RequiredFieldValidator> <asp:CompareValidator ID="valComparePasswords" runat="server" ControlToCompare="Password" SetFocusOnError="true" ControlToValidate="ConfirmPassword" Display="Dynamic" ErrorMessage="Password and Confirmation Password must match." ValidationGroup="CreateUserWizard1">* </asp:CompareValidator> </td> </tr> <tr> <td class="fieldname">E-mail:</td> <td><asp:TextBox runat="server" ID="Email" Width="100%"/></td> <td> <asp:RequiredFieldValidator ID="valRequireEmail" runat="server" ControlToValidate="Email" SetFocusOnError="true" Display="Dynamic"ErrorMessage="E-mail is required." ValidationGroup="CreateUserWizard1">* </asp:RequiredFieldValidator> <asp:RegularExpressionValidator runat="server" ID="valEmailPattern" Display="Dynamic" SetFocusOnError="true" ValidationGroup="CreateUserWizard1" ControlToValidate="Email" ValidationExpression="\w+@\w+([-.]\w+)*\.\w+([-.]\w+)*" ErrorMessage="The E-mail address is not well-formed.">* </asp:RegularExpressionValidator> </td> </tr> <tr> <td class="fieldname">Security question:</td> <td><asp:TextBox runat="server" ID="Question" Width="100%"/></td> <td> <asp:RequiredFieldValidator ID="valRequireQuestion" runat="server" ControlToValidate="Question" SetFocusOnError="true" Display="Dynamic" ErrorMessage="Security question is required." ValidationGroup="CreateUserWizard1">* </asp:RequiredFieldValidator> </td> </tr>++ <tr> <td class="fieldname">Security answer:</td> <td><asp:TextBox runat="server" ID="Answer" Width="100%" /></td> <td> <asp:RequiredFieldValidator ID="valRequireAnswer" runat="server" ControlToValidate="Answer" SetFocusOnError="true" Display="Dynamic" ErrorMessage="Security answer is required." ValidationGroup="CreateUserWizard1">* </asp:RequiredFieldValidator> </td> </tr> <tr> <td colspan="3" align="right"> <asp:Label ID="ErrorMessage" SkinID="FeedbackKO" runat="server" EnableViewState="False"></asp:Label> </td> </tr> </table> <asp:ValidationSummary ValidationGroup="CreateUserWizard1" ID="ValidationSummary1" runat="server" ShowMessageBox="True" ShowSummary="False" /> </ContentTemplate> </asp:CreateUserWizardStep> <asp:WizardStep runat="server" Title="Set preferences"> <div class="sectiontitle">Set-up your profile</div> <p></p>All settings in this section are required only if you want to order products from our e-store. However, we ask you to fill in these details in all cases, because they help us know our target audience, and improve the site and its contents accordingly. Thank you for your cooperation! <p></p> <mb:UserProfile ID="UserProfile1" runat="server" /> </asp:WizardStep> <asp:CompleteWizardStep runat="server"></asp:CompleteWizardStep> </WizardSteps> <MailDefinition BodyFileName="~/RegistrationMail.txt" From="webmaster@effectivedotnet.com" Subject="The Beer House: your new account"> </MailDefinition> </asp:CreateUserWizard> </asp:Content>
The CreateUserWizard's <MailDefinition> section contains all the settings needed for sending the confirmation mail. The most interesting property is BodyFileName, which references a disk file containing the mail's body text. In this file you will typically write a welcome message, and maybe the credentials used to register, so that users will be reminded of the username and password that they selected for your site Following is the content of RegistrationMail.txt that specifies the body text:
Thank you for registering to The Beer House web site! Following are your credentials you selected for logging-in: UserName: <% UserName %> Password: <% Password %> See you online! - The Beer House Team
Note |
This example is e-mailing the username and password because this is a low-risk site and we chose user-friendliness over the tightest possible security. Besides, I wanted to demonstrate how to use placeholders in the body text file (<% UserName %> and <% Password %>). For serious e-commerce sites or in situations where your company (or your client) doesn't approve of e-mailing usernames and passwords, you should not follow this example! |
The page's code-behind file is impressively short: You only need to handle the wizard's FinishButton Click event and have it call the UserProfile's SaveProfile method. The code that implements the registration it not placed here because it's handled by the user control:
protected void CreateUserWizard1_FinishButtonClick( object sender, WizardNavigationEventArgs e) { UserProfile1.SaveProfile(); }
Figure 4-16 is a screenshot of the registration page on the first step of the wizard.
Figure 4-17 shows the second step, enabling users to set up their profile.
Under the login box is a link to PasswordRecover.aspx, which allows a user to recover a forgotten password, by sending an e-mail with the credentials. The page uses a PasswordRecovery control with a custom template for the two steps (entering the username and answering the secret question). The code follows:
<%@ Page Language="C#" MasterPageFile="~/Template.master" AutoEventWireup="true" CodeFile="PasswordRecovery.aspx.cs" Inherits="PasswordRecovery" Title="The Beer House - Password Recovery" %> <asp:Content ID="MainContent" ContentPlaceHolderID="MainContent" Runat="Server"> <div class="sectiontitle">Recover your password</div> <p></p>If you forgot your password, you can use this page to have it sent to you by e-mail. <p></p> <asp:PasswordRecovery ID="PasswordRecovery1" runat="server"> <UserNameTemplate> <div class="sectionsubtitle">Step 1: enter your username</div> <p></p> <table cellpadding="2"> <tr> <td width="80" class="fieldname">Username:</td> <td width="300"> <asp:TextBox ID="UserName" runat="server" Width="100%" /> </td> <td> <asp:RequiredFieldValidator ID="valRequireUserName" runat="server" ControlToValidate="UserName" SetFocusOnError="true" Display="Dynamic" ErrorMessage="Username is required." ValidationGroup="PasswordRecovery1">* </asp:RequiredFieldValidator> </td> </tr> <td colspan="3" align="right"> <asp:Label ID="FailureText" runat="server" SkinID="FeedbackKO" EnableViewState="False" /> <asp:Button ID="SubmitButton" runat="server" CommandName="Submit" Text="Submit" ValidationGroup="PasswordRecovery1" /> </td> </table> </UserNameTemplate> <QuestionTemplate> <div class="sectionsubtitle">Step 2: answer the following question</div> <p></p> <table cellpadding="2"> <tr> <td width="80" class="fieldname">Username:</td> <td width="300"> <asp:Literal ID="UserName" runat="server" /> </td> <td></td> </tr> <tr> <td class="fieldname">Question:</td> <td><asp:Literal ID="Question" runat="server"></asp:Literal></td> <td></td> </tr> <tr> <td class="fieldname">Answer:</td> <td><asp:TextBox ID="Answer" runat="server" Width="100%" /> </td> <td> <asp:RequiredFieldValidator ID="valRequireAnswer" runat="server" ControlToValidate="Answer" SetFocusOnError="true" Display="Dynamic" ErrorMessage="Answer is required." ValidationGroup="PasswordRecovery1">* </asp:RequiredFieldValidator> </td> </tr> <tr> <td colspan="3" align="right"> <asp:Label ID="FailureText" runat="server" SkinID="FeedbackKO" EnableViewState="False" /> <asp:Button ID="SubmitButton" runat="server" CommandName="Submit" Text="Submit" ValidationGroup="PasswordRecovery1" /> </td> </tr> </table> </QuestionTemplate> <SuccessTemplate> <asp:Label runat="server" ID="lblSuccess" SkinID="FeedbackOK" Text="Your password has been sent to you." /> </SuccessTemplate> <MailDefinition BodyFileName="~/PasswordRecoveryMail.txt" From="webmaster@effectivedotnet.com" Subject="The Beer House: your password"> </MailDefinition> </asp:PasswordRecovery> </asp:Content>
The body of the mail sent with the credentials is almost the same as the previous one, so we won't show it again here. Figure 4-18 shows a couple of screenshots for the two-step password recovery process.
Once users log in, they can go to the EditProfile.aspx page, linked in the top-right corner of any page, and change their password or other profile settings. The password-changing functionality is implemented by way of a custom ChangePassword control, and the profile settings functionality is handled by the UserProfile control we already developed. Following is the code for the EditProfile.aspx file (with some layout code removed for clarity and brevity):
<%@ Page Language="C#" MasterPageFile="~/Template.master" AutoEventWireup="true" CodeFile="EditProfile.aspx.cs" Inherits="EditProfile" Title="The Beer House - Edit Profile" %> <%@ Register Src="Controls/UserProfile.ascx" TagName="UserProfile" TagPrefix="mb" %> <asp:Content ID="MainContent" ContentPlaceHolderID="MainContent" Runat="Server"> <div class="sectiontitle">Change your password</div><p></p> <asp:ChangePassword ID="ChangePassword1" runat="server"> <ChangePasswordTemplate> Current password: <asp:TextBox ID="CurrentPassword" TextMode="Password" runat="server" /> ...validators here... New password: <asp:TextBox ID="NewPassword" TextMode="Password" runat="server" /> ...validators here... Confirm password: <asp:TextBox ID="ConfirmNewPassword" TextMode="Password" runat="server" /> ...validators here... <asp:Label ID="FailureText" runat="server" SkinID="FeedbackKO" /> <asp:Button ID="ChangePasswordPushButton" runat="server" CommandName="ChangePassword" Text="Change Password" ValidationGroup="ChangePassword1" /> <asp:ValidationSummary runat="server" ID="valChangePasswordSummary" ValidationGroup="ChangePassword1" ShowMessageBox="true" ShowSummary="false" /> </ChangePasswordTemplate> <SuccessTemplate> <asp:Label runat="server" ID="lblSuccess" SkinID="FeedbackOK" Text="Your password has been changed successfully." /> </SuccessTemplate> <MailDefinition BodyFileName="~/ChangePasswordMail.txt" From="webmaster@effectivedotnet.com" Subject="The Beer House: password changed"> </MailDefinition> </asp:ChangePassword> <p></p> <hr width="100%"size="1" noshade /> <div class="sectiontitle">Change your profile</div> <p></p> Some settings in this section are only required if you want to order products from our e-store. However, we ask you to fill in these details in all cases, because they help us know our target audience, and improve the site and its contents accordingly. Thank you for your cooperation! <p></p> <mb:UserProfile ID="UserProfile1" runat="server" /> <asp:Label runat="server" ID="lblFeedbackOK" SkinID="FeedbackOK" Text="Profile updated successfully" Visible="false" /> <asp:Button runat="server" ID="btnUpdate" ValidationGroup="EditProfile" Text="Update Profile" OnClick="btnUpdate_Click" /> </asp:Content>
In the code-behind you don't have to do anything except handle the Update Profile Submit button's Click event, where you call the UserProfile control's SaveProfile method, and then show a confirmation message:
protected void btnUpdate_Click(object sender, EventArgs e) { UserProfile1.SaveProfile(); lblFeedbackOK.Visible = true; }
Figure 4-19 shows part of this page at runtime.
The last thing to do on this page is to ensure that anonymous users — who, of course, don't have a password or profile to change — cannot access this page. To do this you can create a <location> section for this page in the root web.config, and then write an <authorization> subsection that denies access to the anonymous user (identified by "?") and grant access to everyone else. This is the code you should add to the web.config file:
<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0"> <system.web> <!-- some settings here...--> </system.web> <location path="EditProfile.aspx"> <system.web> <authorization> <deny users="?" /> <allow users="*" /> </authorization> </system.web> </location> </configuration>
In Chapter 2 we created a base page class from which all other pages inherit. One of the tasks of this class is to set the page's Theme property according to what is stored in a Session variable, which tells us what theme the user selected from the Themes drop-down list near the top-right corner. The problem with Session variables is that they only exist as long as the user's session is active, so we'll have to store this value in a persistent location. Thankfully, it turns out that we have a great place to store this — in the user profile. The following code highlights the changes done to the base page to allow us to save the Profile.Preferences.Theme property in the user's profile, and because we're putting it in the base page class we will not have to do this in all the other pages:
public class BasePage : System.Web.UI.Page { protected override void OnPreInit(EventArgs e) { string id = Globals.ThemesSelectorID; if (id.Length > 0) { if (this.Request.Form["__EVENTTARGET"] == id && !string.IsNullOrEmpty(this.Request.Form[id])) { this.Theme = this.Request.Form[id]; (HttpContext.Current.Profile as ProfileCommon).Preferences.Theme = this.Theme; } else { if (!string.IsNullOrEmpty( (HttpContext.Current.Profile as ProfileCommon).Preferences.Theme)) { this.Theme = (HttpContext.Current.Profile as ProfileCommon).Preferences.Theme; } } } base.OnPreInit(e); } protected override void OnLoad(EventArgs e) { ... } }
As mentioned before, we must also handle the Profile_MigrateAnonymous global event, so that when an anonymous user selects a theme and then logs in, his or her favorite theme will be migrated from the anonymous profile to the authenticated one. After this, the old profile can be deleted from the data store, and the anonymous ID can be deleted as well. Following is the complete code:
void Profile_MigrateAnonymous(object sender, ProfileMigrateEventArgs e) { // get a reference to the previously anonymous user's profile ProfileCommon anonProfile = this.Profile.GetProfile(e.AnonymousID); // if set, copy its Theme to the current user's profile if (!string.IsNullOrEmpty(anonProfile.Preferences.Theme)) this.Profile.Preferences.Theme = anonProfile.Preferences.Theme; // delete the anonymous profile ProfileManager.DeleteProfile(e.AnonymousID); AnonymousIdentificationModule.ClearAnonymousIdentifier(); }
Now that the end-user part of the work is done, we only have the administration section to complete. The ~/Admin/Default.aspx page linked by the Admin menu item is the administrator's home page, which contains links to all the administrative functions. First we will develop the page used to manage users and their profiles. To protect all the pages placed under the Admin folder against unauthorized access, you should add a web.config file under this ~/Admin folder, and write an <authorization> section that grants access to the Administrators role and denies access to everyone else. In the "Design" section is a sample snippet to demonstrate this.
This page's user interface can be divided into three parts:
The first part tells the administrator the number of registered users, and how many of them are currently online.
The second part provides controls for finding and listing the users. There is an "alphabet bar" with all the letters of the alphabet; when one is clicked, a grid is filled with all the users having names starting with that letter. Additionally, an All link is present to show all users with a single click. The search functionality allows administrators to search for users by providing a partial username or e-mail address.
The third part of the page contains a grid that lists users and some of their properties.
The following code provides the user interface for the first two parts:
<%@ Page Language="C#" MasterPageFile="~/Template.master" AutoEventWireup="true" CodeFile="ManageUsers.aspx.cs" Inherits="ManageUsers" Title="The Beer House - Account management" %> <asp:Content ID="MainContent" ContentPlaceHolderID="MainContent" Runat="Server"> <div class="sectiontitle">Account Management</div> <p></p> <b>- Total registered users: <asp:Literal runat="server" ID="lblTotUsers" /><br /> - Users online now: <asp:Literal runat="server" ID="lblOnlineUsers" /></b> <p></p> Click one of the following link to display all users whose name begins with that letter: <p></p> <asp:Repeater runat="server" ID="rptAlphabet" OnItemCommand="rptAlphabet_ItemCommand"> <ItemTemplate><asp:LinkButton runat="server" Text='<%# Container.DataItem %>' CommandArgument='<%# Container.DataItem %>' /> </ItemTemplate> </asp:Repeater> <p></p> Otherwise use the controls below to search users by partial username or e-mail: <p></p> <asp:DropDownList runat="server" ID="ddlSearchTypes"> <asp:ListItem Text="UserName" Selected="true" /> <asp:ListItem Text="E-mail" /> </asp:DropDownList> contains <asp:TextBox runat="server" ID="txtSearchText" /> <asp:Button runat="server" ID="btnSearch" Text="Search" OnClick="btnSearch_Click" />
As you see, the alphabet bar is built by a Repeater control, not a fixed list of links. The Repeater will be bound to an array of characters, displayed as links. I used a Repeater instead of static links for a couple of reasons. First, this will make it much easier to change the bar's layout, if you want to do so later, because you need to change the template, not a series of links. Second, if you decide to add localization to this page later, the Repeater's template could remain exactly the same, and it would be sufficient to bind it to a different array containing the selected language's alphabet.
The third part of the page that lists users and some of their properties contains a GridView, which is a powerful new grid added to ASP.NET 2.0 that has been already introduced in Chapter 3. GridView can automatically take care of sorting, paging, and editing, without requiring us to write a lot of code. In this particular instance, however, I am only using the basic functionality; we'll use other advanced features of the GridView in subsequent chapters. You need to define the number and types of columns for the grid within the control's <Columns> section. Following is a list of columns for the grid:
Use BoundField columns (the GridView's version of the old DataGrid's BoundColumn) for the username, the creation date, and last access date. These values will be displayed as normal strings.
Use a CheckBoxField column to show the user's IsApproved property by means of a read-only checkbox (this would have required a TemplateColumn in the old DataGrid, which would have been much harder to implement).
Use a HyperLinkField to show the user's e-mail as an active link that uses the "mailto:" protocol to open the user's default mail client when the link is clicked.
Use another HyperLinkField column to show an edit image that will redirect to a page called EditUser.aspx when clicked. This link will include the username on the querystring and will allow an administrator to edit a user's profile.
Finally, use a ButtonField column to produce a graphical Delete button. If you set the column's ButtonType property to "Image", you can also use its ImageUrl property to specify the URL of the image to display.
Following is the complete code used to define the grid:
<asp:GridView ID="gvwUsers" runat="server" AutoGenerateColumns="false" DataKeyNames="UserName" OnRowCreated="gvwUsers_RowCreated" OnRowDeleting="gvwUsers_RowDeleting"> <Columns> <asp:BoundField HeaderText="UserName" DataField="UserName" /> <asp:HyperLinkField HeaderText="E-mail" DataTextField="Email" DataNavigateUrlFormatString="mailto:{0}" DataNavigateUrlFields="Email" /> <asp:BoundField HeaderText="Created" DataField="CreationDate" DataFormatString="{0:MM/dd/yy h:mm tt}" /> <asp:BoundField HeaderText="Last activity" DataField="LastActivityDate" DataFormatString="{0:MM/dd/yy h:mm tt}" /> <asp:CheckBoxField HeaderText="Approved" DataField="IsApproved" HeaderStyle-HorizontalAlign="Center" ItemStyle-HorizontalAlign="Center" /> <asp:HyperLinkField Text="<img src='../images/edit.gif' border='0' />" DataNavigateUrlFormatString="EditUser.aspx?UserName={0}" DataNavigateUrlFields="UserName" /> <asp:ButtonField CommandName="Delete" ButtonType="Image" ImageUrl="~/images/delete.gif" /> </Columns> <EmptyDataTemplate>No users found for the specified criteria</EmptyDataTemplate> </asp:GridView> </asp:Content>
Before looking at the code-behind file, I want to point out another small, but handy, new feature: The grid has a <EmptyDataTemplate> section that contains the HTML markup to show when the grid is bound to an empty data source. This is a very cool feature because you can use this template to show a message when a search produces no results. Under ASP.NET 1.x, you had to write some code to check for this situation, and then hide the grid and show a Literal or a Label instead. The 2.0 solution is much simpler and more elegant.
In the page's code-behind file there is a class-level MemershipUserCollection object that is initialized with all the user information returned by Membership.GetAllUsers. The Count property of this collection is used in the page's Load event to show the total number of registered users, together with the number of online users. In the same event, we also create the array of letters for the alphabet bar, and bind it to the Repeater control. The code is shown below:
public partial class ManageUsers : BasePage { private MembershipUserCollection allUsers = Membership.GetAllUsers(); protected void Page_Load(object sender, EventArgs e) { if (!this.IsPostBack) { lblTotUsers.Text = allUsers.Count.ToString(); lblOnlineUsers.Text = Membership.GetNumberOfUsersOnline().ToString(); string[] alphabet = "A;B;C;D;E;F;G;J;K;L;M;N;O;P;Q;R;S;T;U;V;W;X;Y;Z;All".Split(';'); rptAlphabet.DataSource = alphabet; rptAlphabet.DataBind(); } } // other methods and event handlers go here... }
The grid is not populated when the page first loads, but rather after the user clicks a link on the alphabet bar or runs a search. This is done in order to avoid unnecessary processing and thus have a fast-loading page. When a letter link is clicked, the Repeater's ItemCommand event is raised. You handle this event to retrieve the clicked letter, and then run a search for all users whose name starts with that letter. If the All link is clicked, you'll simply show all users. Because this page also supports e-mail searches, a "SearchByEmail" attribute is added to the control and set to false, to indicate that the search is by username by default. This attribute is stored in the grid's Attributes collection so that it is persisted in the view state, and doesn't get lost during a postback. Here's the code:
protected void rptAlphabet_ItemCommand(object source, RepeaterCommandEventArgs e) { gvwUsers.Attributes.Add("SearchByEmail", false.ToString()); if (e.CommandArgument.ToString().Length == 1) { gvwUsers.Attributes.Add("SearchText", e.CommandArgument.ToString() + "%"); BindUsers(false); } else { gvwUsers.Attributes.Add("SearchText", ""); BindUsers(false); } }
The code that actually runs the query and performs the binding is in the BindUsers method. It takes a Boolean value as an input parameter that indicates whether the allUsers collection must be repopulated (necessary just after a user is deleted). The text to search for and the search mode (e-mail or username) are not passed as parameters, but rather are stored in the grid's Attributes. Below is the code:
private void BindUsers(bool reloadAllUsers) { if (reloadAllUsers) allUsers = Membership.GetAllUsers(); MembershipUserCollection users = null; string searchText = ""; if (!string.IsNullOrEmpty(gvwUsers.Attributes["SearchText"])) searchText = gvwUsers.Attributes["SearchText"]; bool searchByEmail = false; if (!string.IsNullOrEmpty(gvwUsers.Attributes["SearchByEmail"])) searchByEmail = bool.Parse(gvwUsers.Attributes["SearchByEmail"]); if (searchText.Length > 0) { if (searchByEmail) users = Membership.FindUsersByEmail(searchText); else users = Membership.FindUsersByName(searchText); } else { users = allUsers; } gvwUsers.DataSource = users; gvwUsers.DataBind(); }
The BindUsers method is also called when the Search button is clicked. In this case, the SeachByEmail attribute will be set according to the value selected in the ddlSearchTypes drop-down list, and the SearchText will be equal to the entered search string with the addition of a leading and a trailing "%" character, so that a full LIKE query is performed:
protected void btnSearch_Click(object sender, EventArgs e) { bool searchByEmail = (ddlSearchTypes.SelectedValue == "E-mail"); gvwUsers.Attributes.Add("SearchText", "%" + txtSearchText.Text + "%"); gvwUsers.Attributes.Add("SearchByEmail", searchByEmail.ToString()); BindUsers(false); }
When the trashcan icon is clicked, the GridView raises the RowDeleting event because the column's CommandName property is set to Delete. From inside this event handler you can use the static methods of the Membership and ProfileManager classes to delete the user account and its accompanying profile. After that, BindUser is called again with true as a parameter, so that the collection of all users is refreshed, and the label displaying the total number of users is also refreshed:
protected void gvwUsers_RowDeleting(object sender, GridViewDeleteEventArgs e) { string userName = gvwUsers.DataKeys[e.RowIndex].Value.ToString(); ProfileManager.DeleteProfile(userName); Membership.DeleteUser(userName); BindUsers(true); lblTotUsers.Text = allUsers.Count.ToString(); }
Deleting a user account is a serious action that can't be undone, so you should have the administrator confirm this action before proceeding! This can be done by adding a JavaScript "confirm" in the link's onclick client-side event, through the button's new OnClientClick property. Since the link is created dynamically, you must handle the grid's RowCreated event to get a reference to each link as soon as its parent row and all its contents are created. Here's the code:
protected void gvwUsers_RowCreated(object sender, GridViewRowEventArgs e) { if (e.Row.RowType == DataControlRowType.DataRow) { ImageButton btn = e.Row.Cells[6].Controls[0] as ImageButton; btn.OnClientClick = "if (confirm(‘Are you sure you want to delete this user account?') == false) return false;"; } }
Note that the script is added only for rows of type DataRow. This requires an explicit check before the RowCreated event is raised, and for the header, footer, and pagination bars (when present). Figure 4-20 is a screenshot of this page, listing all current users.
The EditUser.aspx page is linked from a row of the ManagedUsers.aspx grid. It takes a username parameter on the querystring, and allows an administrator to see all the membership details about that user (i.e., the properties of the MembershipUser object representing that user), and supports editing the user's personal profile. The user interface of the page is simple and is divided in three sections:
The first section shows the data read from MembershipUser. All controls are read-only, except for those that are bound to the IsApproved and IsLockedOut properties. For IsLockedOut, you can set it to false to unlock a user account, but you can't set it to true to lock a user account, as only the membership provider can lock out a user.
The second section contains a CheckBoxList that displays all the roles defined for the application, and allows the administrator to add or remove users to and from roles. There is also a TextBox control and a button to create a new role.
The third and last section displays a user's profile and allows edits to the profile, through the UserProfile user control developed earlier.
Following is the code for EditUser.aspx:
<%@ Page Language="C#" MasterPageFile="~/Template.master" AutoEventWireup="true" CodeFile="EditUser.aspx.cs" Inherits="EditUser" Title="The Beer House - Edit User" %> <%@ Register Src="../Controls/UserProfile.ascx" TagName="UserProfile" TagPrefix="mb" %> <asp:Content ID="MainContent" ContentPlaceHolderID="MainContent" Runat="Server"> <div class="sectiontitle">General user information</div> <p></p> <table cellpadding="2"> <tr> <td width="130" class="fieldname">UserName:</td> <td width="300"><asp:Literal runat="server" ID="lblUserName" /></td> </tr> <tr> <td class="fieldname">E-mail:</td> <td><asp:HyperLink runat="server" ID="lnkEmail" /></td> </tr> <tr> <td class="fieldname">Registered:</td> <td><asp:Literal runat="server" ID="lblRegistered" /></td> </tr> <tr> <td class="fieldname">Last Login:</td> <td><asp:Literal runat="server" ID="lblLastLogin" /></td> </tr> <tr> <td class="fieldname">Last Activity:</td> <td><asp:Literal runat="server" ID="lblLastActivity" /></td> </tr> <tr> <td class="fieldname">Online Now:</td> <td><asp:CheckBox runat="server" ID="chkOnlineNow" Enabled="false" /></td> </tr> <tr> <td class="fieldname">Approved:</td> <td><asp:CheckBox runat="server" ID="chkApproved" AutoPostBack="true" OnCheckedChanged="chkApproved_CheckedChanged" /></td> </tr> <tr> <td class="fieldname">Locked Out:</td> <td><asp:CheckBox runat="server" ID="chkLockedOut" AutoPostBack="true" OnCheckedChanged="chkLockedOut_CheckedChanged" /></td> </tr> </table> <p></p> <div class="sectiontitle">Edit user's roles</div> <p></p> <asp:CheckBoxList runat="server" ID="chklRoles" RepeatColumns="5" CellSpacing="4" /> <table cellpadding="2" width="450"> <tr><td align="right"> <asp:Label runat="server" ID="lblRolesFeedbackOK" SkinID="FeedbackOK" Text="Roles updated successfully" Visible="false" /> <asp:Button runat="server" ID="btnUpdateRoles" Text="Update" OnClick="btnUpdateRoles_Click" /> </td></tr> <tr><td align="right"> <small>Create new role: </small> <asp:TextBox runat="server" ID="txtNewRole" /> <asp:RequiredFieldValidator ID="valRequireNewRole" runat="server" ControlToValidate="txtNewRole" SetFocusOnError="true" ErrorMessage="Role name is required." ValidationGroup="CreateRole">* </asp:RequiredFieldValidator> <asp:Button runat="server" ID="btnCreateRole" Text="Create" ValidationGroup="CreateRole" OnClick="btnCreateRole_Click" /> </td></tr> </table> <p></p> <div class="sectiontitle">Edit user's profile</div> <p></p> <mb:UserProfile ID="UserProfile1" runat="server" /> <table cellpadding="2" width="450"> <tr><td align="right"> <asp:Label runat="server" ID="lblProfileFeedbackOK" SkinID="FeedbackOK" Text="Profile updated successfully" Visible="false" /> <asp:Button runat="server" ID="btnUpdateProfile" ValidationGroup="EditProfile" Text="Update" OnClick="btnUpdateProfile_Click" /> </td></tr> </table> </asp:Content>
When the page loads, the username parameter is read from the querystring, a MembershipUser instance is retrieved for that user, and the values of its properties are shown by the first section's controls:
public partial class EditUser : BasePage { string userName = ""; protected void Page_Load(object sender, EventArgs e) { // retrieve the username from the querystring userName = this.Request.QueryString["UserName"]; lblRolesFeedbackOK.Visible = false; lblProfileFeedbackOK.Visible = false; if (!this.IsPostBack) { UserProfile1.UserName = userName; // show the user's details MembershipUser user = Membership.GetUser(userName); lblUserName.Text = user.UserName; lnkEmail.Text = user.Email; lnkEmail.NavigateUrl = "mailto:" + user.Email; lblRegistered.Text = user.CreationDate.ToString("f"); lblLastLogin.Text = user.LastLoginDate.ToString("f"); lblLastActivity.Text = user.LastActivityDate.ToString("f"); chkOnlineNow.Checked = user.IsOnline; chkApproved.Checked = user.IsApproved; chkLockedOut.Checked = user.IsLockedOut; chkLockedOut.Enabled = user.IsLockedOut; BindRoles(); } } // other methods and event handlers go here... }
In the Page_Load event handler you also call the BindRoles method, shown below, which fills a CheckBoxList with all the available roles and then retrieves the roles the user belongs to, and finally selects them in the CheckBoxList:
private void BindRoles() { // fill the CheckBoxList with all the available roles, and then select // those that the user belongs to chklRoles.DataSource = Roles.GetAllRoles(); chklRoles.DataBind(); foreach (string role in Roles.GetRolesForUser(userName)) chklRoles.Items.FindByText(role).Selected = true; }
When the Update Roles button is pressed, the user is first removed from all her roles, and then is added to the selected ones. The first remove is necessary because a call to Roles.AddUserToRole will fail if the user is already a member of that role. As part of the following code, you use a new feature in C# 2.0 called a generic list. This is a list collection that enables you to specify the datatype you wish to support for objects stored in the list. When you declare an instance of this collection, you have to indicate which datatype you want to store in it by enclosing it in angle brackets. Therefore, if you say "List<string>" you are asking for a list collection that is strongly typed to accept strings. You could have also asked for a collection of any other datatype, including any custom class you might create to hold related data.
Here's the code for the UpdateRoles button-click event handler:
protected void btnUpdateRoles_Click(object sender, EventArgs e) { // first remove the user from all roles... string[] currRoles = Roles.GetRolesForUser(userName); if (currRoles.Length > 0) Roles.RemoveUserFromRoles(userName, currRoles); // and then add the user to the selected roles List<string> newRoles = new List<string>(); foreach (ListItem item in chklRoles.Items) { if (item.Selected) newRoles.Add(item.Text); } Roles.AddUserToRoles(userName, newRoles.ToArray()); lblRolesFeedbackOK.Visible = true; }
As you see, you don't make individual calls to Roles.AddUserToRole for each selected role. Instead, you first fill a list of strings with the names of the selected roles, and then make a single call to Roles.AddUserToRoles. When the Create Role button is pressed, you first check to see if a role with the same is already present, and if not you create it. Then, the BindRoles method is called to refresh the list of available roles:
protected void btnCreateRole_Click(object sender, EventArgs e) { if (!Roles.RoleExists(txtNewRole.Text.Trim())) { Roles.CreateRole(txtNewRole.Text.Trim()); BindRoles(); } }
When the Approved checkbox is clicked, an auto-postback is made, and in its event handler you update the MembershipUser object's IsApproved property according to the checkbox's value, and then save the change:
protected void chkApproved_CheckedChanged(object sender, EventArgs e) { MembershipUser user = Membership.GetUser(userName); user.IsApproved = chkApproved.Checked; Membership.UpdateUser(user); }
It works in a similar way for the Locked Out checkbox, except that the corresponding MembershipUser property is read-only, and the user is unlocked by calling the UnlockUser method. After this is done, the checkbox is made read-only because you can't lock out a user here (as mentioned previously). Take a look at the code:
protected void chkLockedOut_CheckedChanged(object sender, EventArgs e) { if (!chkLockedOut.Checked) { MembershipUser user = Membership.GetUser(userName); user.UnlockUser(); chkLockedOut.Enabled = false; } }
Finally, when the profile box's Update button is clicked, a call to the UserProfile's SaveProfile is made, as you've done in other pages:
protected void btnUpdateProfile_Click(object sender, EventArgs e) { UserProfile1.SaveProfile(); lblProfileFeedbackOK.Visible = true; }
Figure 4-21 shows a screenshot of this page in action.