Click here to Skip to main content
Click here to Skip to main content
Technical Blog

Tagged as

Code-First Migration and Extending Identity Accounts in ASP.NET MVC 5 and Visual Studio 2013

, 18 Dec 2013 CPOL
Rate this:
Please Sign up or sign in to vote.
In this post we will review setting up the basic Identity accounts, pointing them at an external SQL Server (or whatever other database you choose) instance instead of the default (local) SQL Server CE in App_Data, and configuring Entity Framework Migrations so seed the database with initial data.

Tree-320The recent release of Visual Studio 2013 and ASP.NET MVC 5 brought significant changes to the Accounts management system (formerly ASP.NET Forms Membership). Also, there have been some minor changes in how we manage Entity Framework Code-First Migrations.

In this post we review setting up and extending the basic Identity accounts in ASP.NET MVC 5, pointing them at an external SQL Server (or whatever other database you choose) instance instead of the default (local) SQL Server CE or SQL Express database created in App-Data, and configuring Entity Framework Migrations to seed the database with initial data.

Image by Wonderlane | Some Rights Reserved

1/28/2014 - IMPORTANT: I strongly recommend cloning the source code used in this article and the next (Extending Identity Accounts and Implementing Role-Based Authentication in ASP.NET MVC 5) from Github. There are a lot of moving parts here, and it is really easy to miss some critical step. You will be able to see more clearly what is going on in each of the sections discussed. Based on some of the questions in the comments (and their ultimate resolution) it seems more likely you will get this up and running faster be starting with the source code: 

Source code on Github: ASP.NET Role-Based Security Example  

The Basic Components of the ASP.NET Identity System

Out of the box, when you create an ASP.NET MVC 5 project using the default template in Visual Studio 2013, you get a basic, ready-to-run website with the elementary Identity and Account management classes already in place. There are also the basic classes and methods required to implement OAuth log-in from various external services such as Google+, Facebook, and Twitter. In the default configuration, the default action is that, when you run the application for the first time and register as a user, the database will be created as a SQL Server CE or SQL Express file in the App_Data folder in your project.

The Identity Account classes in the Default MVC 5 Project Solution Explorer:

solution-explorer-identity-classes

In the above, the IdentityModel.cs file is where the essential Identity system components are defined. Opening that file in the code editor, we see two classes defined:

Code in the IdentityModel.cs Code File:
using Microsoft.AspNet.Identity.EntityFramework;
  
namespace DbMigrationExample.Models
{
    public class ApplicationUser : IdentityUser
    {
    }
  
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext() : base("DefaultConnection")
        {
        }
    }
}

The ApplicationUser class, which inherits from a framework class IdentityUser. This is the basic identity unit for managing individual accounts in the ASP.NET MVC 5 Account system. This class is empty as defined in the default project code, and so brings with it only those properties exposed by the base class IdentityUser. However, we can extend the ApplicationUser class by adding our own properties, which will then be reflected in the generated database. More on this in a moment.

We also find here the class ApplicationDbContext. This is the Entity Framework context used to manage interaction between our application and the database where our Account data is persisted (which may, or may not be the same database that will be used by the rest of our application). Important to note that this class inherits not from DBContext (as is the usual case with EF), but instead from IdentityDbContext. In other words, ApplicationDbContext inherits from a pre-defined DB context defined as part of Microsoft.AspNet.Identity.EntityFramework which contains the "Code-First" base classes for the Identity system.

Between these two classes, the MVC framework has provided the basics for generating and consuming the complete Identity Account database. However, the basic classes are extensible, so we can tailor things to suit our needs.

Lastly, note the AccountViewModels.cs file. Here are defined the ViewModels which are actually used by the Views in our application, such that only that information needed to populate a view and perform whatever actions need to be performed is exposed to the public-facing web. View Models are not only an effective design component from an architectural standpoint, but also prevent exposing more data than necessary.

Configuring the Database Connection

As mentioned previously, the default MVC project will create a SQL CE or SQL Express in the project's App_Data folder unless we tell it to do otherwise. All we really need to do is change the connection string used by the ApplicationDbContext, and point it at our target database.

The ApplicationDbContext class passes a connection string name to the base class in the constructor, namely "DefaultConnection." If we open the Web.config file, we find that under the <connectionStrings> element there is a single node, in which the "DefaultConnection" string is added to the connection strings collection:

The Default Connection String in Web.config:
<connectionStrings><add name="DefaultConnection" 
    connectionString="Data Source=(LocalDb)\v110;AttachDbFilename=|DataDirectory|
    \aspnet-DbMigrationExample-20131027114355.mdf;
    Initial Catalog=aspnet-DbMigrationExample-20131027114355;
    Integrated Security=True"
    providerName="System.Data.SqlClient" />
</connectionStrings>

The easiest way to change our target database is to simply change the connection string details for "DefaultConnection" in Web.config. In this case, we will swap out the SQL CE or SQL Express connection for a SQL Server development database I have running on my development machine (obviously, you can point this at any database you wish, or simply leave it set to use the local SQL CE or SQL Express database):

Pointing "DefaultConnection" at a Local SQL Server Instance:
<connectionStrings><add name="DefaultConnection" 
    connectionString="Data Source=XIVMAIN\XIVSQL;
    Initial Catalog=DbMigrationExample;Integrated Security=True"
    providerName="System.Data.SqlClient" />
</connectionStrings>

Now, if I run the application, I am greeted with the default home screen offered by the VS 2013 MVC project template:

The Home Page of the Default MVC Project Template:

default-home-page-aspnet-mvc-5-before-register

From here, I click on the "register" link upper right:

The Registration Page of the Default MVC Project Template:

default-home-page-aspnet-mvc-5-register

When I complete registration and hit the "Register" button, I am redirected back to the home page. However, we can see now, in the upper right corner, that I am, in fact, signed in as a registered user:

default-home-page-aspnet-mvc-5-after-register

None of this is surprising. The reason we just went through that little exercise was to see the resulting database created once we registered. If I open SQL Server Management Studio (SSMS), I should find a new Database named DbMigrationExample:

The New SQL Server Database Viewed in SSMS:

SSMS-new-database

Note the tables created here. Despite the fact that only ApplicationUser is defined in our application, all of the above are created as a result of the IdentityDbContext class from which ApplicationDbContext inherits.

The default project configuration only actually makes use of the data from dbo.AspNetUsers. However, you can see that a full range of identity management tables have been created, including those for role management, and external authorization (using Google/Facebook/Twitter accounts).

Configuring Entity Framework Migrations and Seeding the Database

As we develop, we may make changes to our classes which need to be reflected in the database. Also, quite often we would like to redeploy our development database either in a fresh state, or possibly with some initial data (or Seed data) present. As with previous version of Entity Framework and MVC, we can use EF Code First Migrations to do this.

NOTE: There is a lot that can be done with Migrations, and a lot of ways to customize what happens. Here we are going to skim the basics. Just know there is a lot more . . .  

Before proceeding, I am going to delete the SQL Server database created when I registered on the site and start fresh. If you did something similar, do the same.

In order to start using Migrations with your project, go to the tools menu in Visual Studio, and select Library Package Manager => Package Manager Console. When the console opens at the bottom of the screen, we simply type:

Enable-Migrations –EnableAutomaticMigrations

Once we hit enter, the console will be busy for a moment as the Migrations folder is configured in our project. When the task is complete, our console window should look like this:

Console After Enable-Migrations Command:

console-enable-migrations 

Seeding the Database with an Initial User Records

For various reasons we may want to deploy our database with some initial records in place . We may need tables for certain static values pre-populated, or we might simply want some test data to work with in development. In our current case, we will deploy the database with a couple of User records pre-populated for testing purposes.

Once we have run the Enable-Migrations command as above, there should be a Migrations folder at the root of our project. If we open Configuration.cs file in that folder, we see this:

Default Code in Migrations Configuration Class:
namespace DbMigrationExample.Migrations
{
    using System;
    using System.Data.Entity;
    using System.Data.Entity.Migrations;
    using System.Linq;
  
    internal sealed class Configuration 
        : DbMigrationsConfiguration<DbMigrationExample.Models.ApplicationDbContext>
    {
        public Configuration()
        {
            AutomaticMigrationsEnabled = true;
        }
  
        protected override void Seed(
            DbMigrationExample.Models.ApplicationDbContext context)
        {
            //  This method will be called after migrating to the latest version.
  
            //  You can use the DbSet<T>.AddOrUpdate() helper extension method 
            //  to avoid creating duplicate seed data. E.g.
            //
            //    context.People.AddOrUpdate(
            //      p => p.FullName,
            //      new Person { FullName = "Andrew Peters" },
            //      new Person { FullName = "Brice Lambson" },
            //      new Person { FullName = "Rowan Miller" }
            //    );
            //
        }
    }
}

We want to modify the Configuration class as follows so that our test user records are created whenever the Database is created. Note that we have added some using statements to the top of the file as well to being in the Microsoft.AspNet.Identity and Microsoft.AspNet.Identity.EntityFramework, namespaces, as well as the Models namespace from our own project:

Modified Code in Migrations Configuration Class:
using System;
using System.Data.Entity;
using System.Data.Entity.Migrations;
using System.Linq;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
using DbMigrationExample.Models;
  
namespace DbMigrationExample.Migrations
{
    internal sealed class Configuration 
        : DbMigrationsConfiguration<DbMigrationExample.Models.ApplicationDbContext>
    {
        public Configuration()
        {
            AutomaticMigrationsEnabled = true;
        }
  
        protected override void Seed(ApplicationDbContext context)
        {
            var manager = new UserManager<ApplicationUser>(
                new UserStore<ApplicationUser>(
                    new ApplicationDbContext()));

            // Create 4 test users:
            for (int i = 0; i < 4; i++)
            {
                var user = new ApplicationUser()
                {
                    UserName = string.Format("User{0}", i.ToString())
                };
                manager.Create(user, string.Format("Password{0}", i.ToString()));
            }
        }
    }
}

Now that we have added code to seed the user table, we run the following command from the console:

Enable-Migration Init Command:
Add-Migration Init 

When the command is finished running (it can take a few seconds) our console window looks like this:

Console Output from Enable-Migration Init:

console-add-migration-init

The previous Add-Migration Init command created the scaffolding code necessary to create the database, but to this point. If we look in the Migrations folder now, we will see a couple new files added containing that code. However, nothing we have done to this point has created or modified an actual database. All the preceding has been set up.

Create/Update the Seeded Database using the Update-Database Command

With all that done, run the following command from the console:

Update-Database 

When the command finishes, your console should look like this:

Console Output from Update-Database:

console-update-database

If all went well, we now see the database has been re-created with all the expected tables as before. Additionally, if we SELECT * FROM dbo.AspNetUsers we find that we now have four test users:

Query Result from dbo.AspNetUsers Table:

query-aspnetusers-table

Now that we have a basic migration strategy in place, let's take a look at extending the elementary ApplicationUser class to incorporate some additional data fields.

Extending the IdentityModel Class with Additional Properties

Under the ASP.NET new Identity Model, it is easier than before to extend the basic user representation to include arbitrary fields suitable for our application management. For example, Let's assume we would like our user information to include an email address, as well as full first and last names. We can add properties for these items to the ApplicationUser class, and then update the Controllers, ViewModels, and Views which rely on Application user for registration and such.

First, let's go back to the ApplicationUser class and add the properties we want:

using Microsoft.AspNet.Identity.EntityFramework;
  
// Add this to bring in Data Annotations:
using System.ComponentModel.DataAnnotations;
  
namespace DbMigrationExample.Models
{
    public class ApplicationUser : IdentityUser
    {
        [Required]
        public string FirstName { get; set; }
  
        [Required]
        public string LastName { get; set; }
  
        [Required]
        public string Email { get; set; }
    }
  
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext()
            : base("DefaultConnection")
        {
        }
    }
}

In the above, we added our three new properties to the ApplicationUser class, and also added the [Required] attribute. To do so, we need to add System.ComponentModel.DataAnnotations to our using statements at the top of the class file.

Update the Register Method of the AccountController

We also need to update the Register method on the AccountController. Currently, the code creates an instance of ApplicationUser and sets only the UserName property:

Currently, Only the UserName Property is Set:
var user = new ApplicationUser() { UserName = model.UserName }; 

We need to add the following (the code following the comment) to make our controller work properly: 

Register Method of AccountController Updated to Set the New User Properties:
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        // Add the following to populate the new user properties
        // from the ViewModel data:
        var user = new ApplicationUser() 
        { 
            UserName = model.UserName, 
            FirstName = model.FirstName,
            LastName = model.LastName,
            Email = model.Email
        };
        var result = await UserManager.CreateAsync(user, model.Password);
        if (result.Succeeded)
        {
            await SignInAsync(user, isPersistent: false);
            return RedirectToAction("Index", "Home");
        }
        else
        {
            AddErrors(result);
        }
    }
  
    // If we got this far, something failed, redisplay form
    return View(model);
}

Update the Register ViewModel

Now that we have added the new properties to our ApplicationUser class, we also need to provide a way for the user to input the values during the registration process. If we open the AccountViewModels.cs file, we see a number of ViewModel classes defined. At the bottom is the RegisterViewModel class. It currently looks like this:

The Default RegisterViewModel Class:
public class RegisterViewModel
{
    [Required]
    [Display(Name = "User name")]
    public string UserName { get; set; }
  
    [Required]
    [StringLength(100, ErrorMessage = 
        "The {0} must be at least {2} characters long.", MinimumLength = 6)]
    [DataType(DataType.Password)]
    [Display(Name = "Password")]
    public string Password { get; set; }
  
    [DataType(DataType.Password)]
    [Display(Name = "Confirm password")]
    [Compare("Password", ErrorMessage = 
        "The password and confirmation password do not match.")]
    public string ConfirmPassword { get; set; }
}

We want to add our new properties, so we modify it as follows:

Modified RegisterViewModel Class:
public class RegisterViewModel
{
    [Required]
    [Display(Name = "User name")]
    public string UserName { get; set; }
  
    [Required]
    [StringLength(100, ErrorMessage = 
        "The {0} must be at least {2} characters long.", MinimumLength = 6)]
    [DataType(DataType.Password)]
    [Display(Name = "Password")]
    public string Password { get; set; }
  
    [DataType(DataType.Password)]
    [Display(Name = "Confirm password")]
    [Compare("Password", ErrorMessage = 
        "The password and confirmation password do not match.")]
    public string ConfirmPassword { get; set; }
  
    [Required]
    [Display(Name = "First name")]
    public string FirstName { get; set; }
  
    [Required]
    [Display(Name = "Last name")]
    public string LastName { get; set; }
  
    [Required]
    [Display(Name = "Email")]
    public string Email { get; set; }
}

Update the Register View

Now that we have that taken care of, we need to also modify the Register.cshtml View to match. In the folder Views => Account open the Register.cshtml file. It should look like this:

The Default Register.cshtml File:
@model DbMigrationExample.Models.RegisterViewModel
@{
    ViewBag.Title = "Register";
}
  
<h2>@ViewBag.Title.</h2>
  
@using (Html.BeginForm("Register", "Account", 
    FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
{
    @Html.AntiForgeryToken()
    <h4>Create a new account.</h4>
    <hr />
    @Html.ValidationSummary()
    <div class="form-group">
        @Html.LabelFor(m => m.UserName, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.UserName, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.Password, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.Password, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.ConfirmPassword, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.ConfirmPassword, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" class="btn btn-default" value="Register" />
        </div>
    </div>
}
  
@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

Add the new properties after the existing form-group element for "ConfirmPassword" as follows:

Modified Register.cshml File:
<h2>@ViewBag.Title.</h2>
    
@using (Html.BeginForm("Register", "Account", 
    FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
{
    @Html.AntiForgeryToken()
    <h4>Create a new account.</h4>
    <hr />
    @Html.ValidationSummary()
    <div class="form-group">
        @Html.LabelFor(m => m.UserName, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.UserName, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.Password, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.Password, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.ConfirmPassword, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.ConfirmPassword, new { @class = "form-control" })
        </div>
    </div>
     
    // Add new properties here:
    <div class="form-group">
        @Html.LabelFor(m => m.FirstName, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.FirstName, new { @class = "form-control" })
        </div>
    </div>
    
    <div class="form-group">
        @Html.LabelFor(m => m.LastName, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.LastName, new { @class = "form-control" })
        </div>
    </div>
  
    <div class="form-group">
        @Html.LabelFor(m => m.Email, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.Email, new { @class = "form-control" })
        </div>
    </div>
    
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" class="btn btn-default" value="Register" />
        </div>
    </div>
}
  
@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

Updating the Database to Reflect Modified Entity Classes

So far, we have modified one of our Data model Entity classes – namely, the ApplicationUser class. In our application, EF is mapping this class to the dbo.AspNetUsers table in our backend. We need to run Migrations again to update things. Before we do that though, there is one more thing we need to do. Our seed method is no longer in sync with what our classes (and shortly, our back-end tables) require. We need to add values for the new FirstName, LastName, and Email properties to our user Seed data:

Updated Seed method:
protected override void Seed(ApplicationDbContext context)
{
    var manager = new UserManager<ApplicationUser>(
        new UserStore<ApplicationUser>(
            new ApplicationDbContext()));
  
    for (int i = 0; i < 4; i++)
    {
        var user = new ApplicationUser()
        {
            UserName = string.Format("User{0}", i.ToString()),
  
            // Add the following so our Seed data is complete:
            FirstName = string.Format("FirstName{0}", i.ToString()),
            LastName = string.Format("LastName{0}", i.ToString()),
            Email = string.Format("Email{0}@Example.com", i.ToString()),
        };
        manager.Create(user, string.Format("Password{0}", i.ToString()));
    }
}

Now, if we were to run the Update-Database command again, the changes to our entity objects will be reflected in the dbo.AspNetUsers table schema, but our seed data will not be updated, because Entity Framework doesn't like to do things which will cause data loss. While there are ways to make this happen, they are beyond the scope of this article. Instead, we will manually delete the database, and then run Update-Database again. However, since EF thinks that we have existing data from our previous migration, we have to use Update-Database -force.

Note, you can also use Add-Migration MyNewMigrationName to add a new migration while preserving the previous one. A little like version control for your database schema. However, you still need to drop the old database of use the -force argument before changes will be applied.  

Once we have deleted the database and run the Update-Database –force command, our console output should look like this:

Console Output Update-Database –force After Deleting Database:

console-update-database-force

Quickly re-running our query shows that indeed, the new fields have been added to our table, and the test data populated:

Query Result from dbo.AspNetUsers Table:

query-aspnetusers-table-updated

User Registration with Updated Registration Form

Now that we have updated our Registration.cshtml View, Controller method, ViewModel, and Database, when we run the application and go to register, we see the updated Registration form:

The Updated Registration Form:

site-registration-with-new-properties

Once we complete the form and hit the Register button, we are logged in successfully, wit our additional data persisted in the database, ready to be used in our application.

Logging in Using Seed Data

Alternatively, we can also log out, and log in as one of our test users by clicking the "Log in" link:

Logging in with Test Data:

log-in-as-test-user

Successful Log-in:

successfully-logged-in-as-test-user

Only the Beginning

The updates to ASP.NET MVC 5 are numerous, and cool. In this article I have kept it really basic, and only scratched the surface of the updated Account management possibilities. The ASP.NET and MVC team has made it easy to do things like add role-based identity management, as well as external log-ins from social networks such as Google +, Facebook, and Twitter.

In my next post, I take a closer look at creating a role-based identity management model for administering a "closed" site.

Additional Resources and Items of Interest

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

John Atten
Software Developer XIV Solutions
United States United States
My name is John Atten, and my username on many of my online accounts is xivSolutions. I am Fascinated by all things technology and software development. I work mostly with C#, Javascript/Node.js, Various flavors of databases, and anything else I find interesting. I am always looking for new information, and value your feedback (especially where I got something wrong!)
Follow on   Twitter   Google+

Comments and Discussions

 
GeneralMy vote of 5 PinmemberKashif_Imran24-Sep-14 22:29 
QuestionWeb forms edition PinmemberDe-Noble3-Sep-14 21:44 
QuestionWell written PinmemberMember 1011881412-Apr-14 18:01 
QuestionAdding an existing database PinmemberKevin Preston7-Apr-14 17:14 
QuestionBreaking issues as of Entity Framework 6.0.1 PinmemberApacheTech Consultancy31-Mar-14 18:07 
AnswerRe: Breaking issues as of Entity Framework 6.0.1 PinmemberJohn Atten31-Mar-14 18:33 
Questionrunning the project on IIS PinmemberMember 1069046622-Mar-14 0:39 
QuestionSign Out PinmemberMember 106389163-Mar-14 1:37 
Questioni got stuck PinmemberHo0otan25-Feb-14 17:54 
AnswerRe: i got stuck PinmemberJohn Atten25-Feb-14 18:16 
AnswerRe: i got stuck PinmemberCédric Hutt31-Mar-14 0:13 
QuestionSeed user after dropping table PinmemberBen__121-Feb-14 4:40 
QuestionUsing Manage view to update profile information? PinmemberAlexHarmes5-Feb-14 4:41 
AnswerRe: Using Manage view to update profile information? PinmemberJohn Atten5-Feb-14 5:12 
GeneralRe: Using Manage view to update profile information? PinmemberAlexHarmes5-Feb-14 9:24 
GeneralRe: Using Manage view to update profile information? PinmemberJohn Atten5-Feb-14 9:49 
GeneralRe: Using Manage view to update profile information? PinmemberAlexHarmes5-Feb-14 23:39 
GeneralRe: Using Manage view to update profile information? PinmemberJohn Atten6-Feb-14 2:56 
GeneralRe: Using Manage view to update profile information? PinmemberAlexHarmes7-Feb-14 5:53 
GeneralRe: Using Manage view to update profile information? PinmemberJohn Atten7-Feb-14 13:19 
GeneralRe: Using Manage view to update profile information? PinmemberAlexHarmes8-Feb-14 6:10 
QuestionSeed() method doesn't work for me PinmemberGary In SD26-Jan-14 3:58 
AnswerRe: Seed() method doesn't work for me PinmemberJohn Atten26-Jan-14 5:10 
GeneralRe: Seed() method doesn't work for me PinmemberGary In SD26-Jan-14 6:30 
GeneralRe: Seed() method doesn't work for me PinmemberGary In SD26-Jan-14 13:30 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.141223.1 | Last Updated 18 Dec 2013
Article Copyright 2013 by John Atten
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid