MobileShop Website Part 33 | User Management System in ASP.NET Core MVC

 


Welcome to Part 33 of our Mobile Shop development series! Today, we are kicking off a critical security and administrative module: the User Management System.

In Step 1, we establish our base operations center by creating the UsersController inside our Admin area. This controller serves as the control hub where store managers can view registered users, assign roles (like Admin, Employee, or Customer), and manage account statuses.

Here is the step-by-step structural breakdown of how this foundational identity controller is constructed and secured.

Step 1: Users Controller Core Architecture

The Security & Identity Control Pipeline

C# / Areas/Admin/Controllers/UsersController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MobileShop.Data;
using MobileShop.Models;
using MobileShop.ViewModels;

namespace MobileShop.Areas.Admin.Controllers
{
    [Area("Admin")]
    [Authorize(Roles = "Admin")]
    public class UsersController : Controller
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager;
        private readonly ApplicationDbContext _context;

        public UsersController(
            UserManager<ApplicationUser> userManager,
            RoleManager<IdentityRole> roleManager,
            ApplicationDbContext context)
        {
            _userManager = userManager;
            _roleManager = roleManager;
            _context = context;
        }
    }
}

Step-by-Step Code Explanation

Structural Routing & Strict Role Security

[Area("Admin")]
[Authorize(Roles = "Admin")]
public class UsersController : Controller
  • [Area("Admin")]: Informs the ASP.NET Core routing engine that this controller belongs inside our isolated administrative folder structure, sorting our workspace cleanly.

  • [Authorize(Roles = "Admin")]: This is your primary security guard rail. By locking the entire controller down to Roles = "Admin", you ensure that standard storefront customers or malicious users can never guess or brute-force the URL path to modify user settings. If a non-admin attempts to access this, the framework instantly blocks them and redirects to an Access Denied landing page.

Core Dependency Injection Services

private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly ApplicationDbContext _context;

To fully manage user accounts, we inject three separate, powerful database management APIs:

  • UserManager<ApplicationUser>: The standard Microsoft Identity manager service. It handles safe password resets, user creation, email confirmations, and direct profile field updates.

  • RoleManager<IdentityRole>: Responsible for the global roles table. It tracks what security roles exist in your mobile shop platform (e.g., creating, updating, or querying the Admin or Customer groups).

  • ApplicationDbContext: Our core Entity Framework entity gateway, allowing us to perform quick, custom joins across standard relational database tables when compiling user summary lists.

Explicit Constructor Initialization

public UsersController(
    UserManager<ApplicationUser> userManager,
    RoleManager<IdentityRole> roleManager,
    ApplicationDbContext context)
{
    _userManager = userManager;
    _roleManager = roleManager;
    _context = context;
}
  • Leverages standard built-in Dependency Injection (DI). When a web request routes into this controller, the ASP.NET Core runtime automatically fetches the pre-configured Identity instances from your service container and pipes them safely into these local read-only backing fields.

In Step 2 of Part 33, we write the primary data coordination engine for our administration panel: the Index Action Method.

This method handles pulling the master registry of users out of Microsoft Identity. It processes server-side keyword filters, tracks down what access roles belong to each individual user, checks customer transaction histories to calculate lifetime purchase counts, and implements clean in-memory pagination to keep page rendering ultra-fast.

Here is the step-by-step breakdown of how this multi-stage retrieval pipeline runs.

Step 2: Advanced User Ingestion & Filtering Workflow

The Multi-Stage Account Aggregation Pipeline

C# / Areas/Admin/Controllers/UsersController.cs (Index Method)
public async Task<IActionResult> Index(string? search, string? role, int page = 1)
{
    var query = _userManager.Users.AsQueryable();

    if (!string.IsNullOrWhiteSpace(search))
        query = query.Where(u => u.Email.Contains(search) || 
                                u.FirstName.Contains(search) || 
                                u.LastName.Contains(search));

    var users = await query
        .OrderByDescending(u => u.CreatedAt)
        .ToListAsync();

    var userViewModels = new List<UserManagementViewModel>();
    foreach (var user in users)
    {
        var roles = await _userManager.GetRolesAsync(user);
        var orderCount = await _context.Orders.CountAsync(o => o.UserId == user.Id);

        if (!string.IsNullOrWhiteSpace(role) && !roles.Contains(role))
            continue;

        userViewModels.Add(new UserManagementViewModel
        {
            UserId = user.Id,
            FullName = user.FullName,
            Email = user.Email!,
            PhoneNumber = user.PhoneNumber ?? "",
            Roles = roles.ToList(),
            IsActive = user.IsActive,
            CreatedAt = user.CreatedAt,
            OrderCount = orderCount
        });
    }

    var pageSize = 20;
    var totalItems = userViewModels.Count;
    var pagedUsers = userViewModels
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToList();

    ViewBag.Roles = await _roleManager.Roles.Select(r => r.Name).ToListAsync();
    ViewBag.CurrentRole = role;
    ViewBag.Search = search;
    ViewBag.CurrentPage = page;
    ViewBag.TotalPages = (int)Math.Ceiling(totalItems / (double)pageSize);

    return View(pagedUsers);
}

Step-by-Step Code Explanation

Deferred Execution Query Setup

public async Task<IActionResult> Index(string? search, string? role, int page = 1)
{
    var query = _userManager.Users.AsQueryable();
  • Parameters: Accepts optional search keywords, an optional structural security role string, and an explicit fallback tracker parameter page = 1 for handling navigation requests.

  • AsQueryable(): This is an essential performance best-practice. Instead of immediately executing a heavy dump of all users into system memory, this sets up an unexecuted IQueryable expression tree block. This allows us to append conditional queries dynamically before hitting the SQL server.

Multi-Field Structural Search Mapping

if (!string.IsNullOrWhiteSpace(search))
    query = query.Where(u => u.Email.Contains(search) || 
                            u.FirstName.Contains(search) || 
                            u.LastName.Contains(search));

var users = await query
    .OrderByDescending(u => u.CreatedAt)
    .ToListAsync();
  • Evaluates if a search string was typed. If true, it adds SQL LIKE statement clauses across multiple data fields (Email, FirstName, and LastName) to isolate matches cleanly.

  • ToListAsync(): Executes the query and pulls the filtered, chronologically sorted (OrderByDescending) user account records into a lightweight backend list.

Role Tracking & Cross-Table Relational Lookups

foreach (var user in users)
{
    var roles = await _userManager.GetRolesAsync(user);
    var orderCount = await _context.Orders.CountAsync(o => o.UserId == user.Id);
  • Because Microsoft Identity stores roles in isolated map tables (AspNetUserRoles), we iterate over our users and call GetRolesAsync(user) for each one to find their active permissions tier.

  • Cross-Context Aggregation: It queries our standard e-commerce context concurrently via CountAsync(o => o.UserId == user.Id) to calculate exactly how many physical checkout invoices this profile has placed.

Post-Filter Drop Guard

if (!string.IsNullOrWhiteSpace(role) && !roles.Contains(role))
    continue;
  • If an administrator has filtered the dashboard to show only a specific role (like Employee), this check checks the user's role list. If they do not match, the code triggers a continue, skipping that record entirely so it never reaches the output model.

View Model Mapping & Population

userViewModels.Add(new UserManagementViewModel
{
    UserId = user.Id,
    FullName = user.FullName,
    Email = user.Email!,
    PhoneNumber = user.PhoneNumber ?? "",
    Roles = roles.ToList(),
    IsActive = user.IsActive,
    CreatedAt = user.CreatedAt,
    OrderCount = orderCount
});
  • Safely translates raw database entity shapes into our specialized, UI-ready UserManagementViewModel package container, protecting underlying framework parameters from client-side vulnerability disclosures.

In-Memory Sub-Pagination Logic

var pageSize = 20;
var totalItems = userViewModels.Count;
var pagedUsers = userViewModels
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToList();
  • To keep screen layouts clean and uniform, we limit view summaries to 20 records per page.

  • Using our page number index, we calculate the dynamic skip window offset—for instance, page 2 executes a skip of $(2 - 1) \times 20 = 20$ records—and safely captures the remaining block via .Take(pageSize).

ViewBag Tracking Delivery Context

ViewBag.Roles = await _roleManager.Roles.Select(r => r.Name).ToListAsync();
ViewBag.CurrentRole = role;
ViewBag.Search = search;
ViewBag.CurrentPage = page;
ViewBag.TotalPages = (int)Math.Ceiling(totalItems / (double)pageSize);

return View(pagedUsers);
  • Collects every global available role name from _roleManager to populate a drop-down filter component on our front end.

  • Packs navigation tracking state parameters securely inside ViewBag tokens to construct clean pagination button groups, returning our cleanly sliced data array straight to the user.

In Step 3 of Part 33, we are creating the front-end user interface: the Index.cshtml view for User Management.

This view provides store administrators with a comprehensive control panel. It includes an interactive top search filter bar, a responsive data grid summarizing user credentials, dynamic state styling badges, and an integrated pagination component to easily browse through your platform's customer and staff directory.

Step 3: User Management UI Anatomy

The User Directory Panel Layout

CSHTML / Areas/Admin/Views/Users/Index.cshtml
@model List<UserManagementViewModel>
@{
    ViewData["Title"] = "Users";
}

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3><i class="bi bi-people"></i> Users</h3>
</div>

<!-- Filters -->
<div class="card mb-4">
    <div class="card-body">
        <form method="get" class="row g-3">
            <div class="col-md-4">
                <input type="text" name="search" class="form-control" placeholder="Search by name or email..." value="@ViewBag.Search" />
            </div>
            <div class="col-md-3">
                <select name="role" class="form-select">
                    <option value="">All Roles</option>
                    @foreach (var role in ViewBag.Roles)
                    {
                        <option value="@role" selected="@(ViewBag.CurrentRole == role ? "selected" : null)">@role</option>
                    }
                </select>
            </div>
            <div class="col-md-2">
                <button type="submit" class="btn btn-outline-primary w-100"><i class="bi bi-search"></i> Filter</button>
            </div>
        </form>
    </div>
</div>

<div class="card shadow-sm">
    <div class="table-responsive">
        <table class="table table-hover mb-0">
            <thead class="table-dark">
                <tr>
                    <th>Name</th>
                    <th>Email</th>
                    <th>Phone</th>
                    <th>Roles</th>
                    <th>Orders</th>
                    <th>Status</th>
                    <th>Joined</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var user in Model)
                {
                    <tr>
                        <td><strong>@user.FullName</strong></td>
                        <td>@user.Email</td>
                        <td>@(string.IsNullOrEmpty(user.PhoneNumber) ? "-" : user.PhoneNumber)</td>
                        <td>
                            @foreach (var role in user.Roles)
                            {
                                <span class="badge bg-primary me-1">@role</span>
                            }
                        </td>
                        <td>@user.OrderCount</td>
                        <td>
                            @if (user.IsActive)
                            {
                                <span class="badge bg-success">Active</span>
                            }
                            else
                            {
                                <span class="badge bg-secondary">Inactive</span>
                            }
                        </td>
                        <td>@user.CreatedAt.ToString("MMM dd, yyyy")</td>
                        <td>
                            <a asp-action="Details" asp-route-id="@user.UserId" class="btn btn-sm btn-outline-primary">
                                <i class="bi bi-eye"></i>
                            </a>
                            <form asp-action="ToggleStatus" method="post" class="d-inline">
                                <input type="hidden" name="id" value="@user.UserId" />
                                <button type="submit" class="btn btn-sm @(user.IsActive ? "btn-outline-warning" : "btn-outline-success")">
                                    <i class="bi @(user.IsActive ? "bi-pause-circle" : "bi-play-circle")"></i>
                                </button>
                            </form>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    </div>
</div>

@if (ViewBag.TotalPages > 1)
{
    <nav class="mt-3">
        <ul class="pagination justify-content-center">
            @for (int i = 1; i <= ViewBag.TotalPages; i++)
            {
                <li class="page-item @(i == ViewBag.CurrentPage ? "active" : "")">
                    <a class="page-link" asp-action="Index" asp-route-page="@i" asp-route-role="@ViewBag.CurrentRole" asp-route-search="@ViewBag.Search">@i</a>
                </li>
            }
        </ul>
    </nav>
}

Step-by-Step UI Layout Explanation

Preserving Search States in the Filter Bar

<input type="text" name="search" class="form-control" placeholder="..." value="@ViewBag.Search" />
...
<option value="@role" selected="@(ViewBag.CurrentRole == role ? "selected" : null)">@role</option>
  • Form method="get": Submits the filters via the URL query string. This enables bookmarking and ensures the parameters are cleanly picked up by the pagination system.

  • State Preservation: By reading back @ViewBag.Search and using an inline ternary operator on the role options, the text fields and drop-downs remain populated with the admin's active filter criteria after the page reloads.

Responsive Data Grid Loop & Badges

@foreach (var role in user.Roles)
{
    <span class="badge bg-primary me-1">@role</span>
}
  • Accounts can hold multiple permission tiers simultaneously (e.g., a user can be both an Employee and an Admin). This nested loop elegantly handles multiple items by outputting every active security group within a separate Bootstrap badge component.

  • Account Status Logic: Evaluates the boolean @user.IsActive variable inline. If true, it paints a green Active badge; if false, it shows a gray Inactive label, allowing administrators to scan the directory for disabled accounts at a glance.

Inline Action Forms & Context-Aware Controls

<a asp-action="Details" asp-route-id="@user.UserId" class="btn btn-sm btn-outline-primary">...</a>

<form asp-action="ToggleStatus" method="post" class="d-inline">
    <input type="hidden" name="id" value="@user.UserId" />
    <button type="submit" class="btn btn-sm @(user.IsActive ? "btn-outline-warning" : "btn-outline-success")">
        <i class="bi @(user.IsActive ? "bi-pause-circle" : "bi-play-circle")"></i>
    </button>
</form>
  • Details Action: Provides a clean view link targeting /Users/Details/{id} to inspect deeper account specifics.

  • State-Altering Security Guard (method="post"): Because changing an account's active state alters data, we wrap the freeze button inside a valid POST form instead of a standard hyperlink. This protects the endpoint against accidental background trigger modifications or malicious link-crawlers.

  • Contextual UI Morphing: The action button automatically shifts style parameters depending on the user's current status:

    • If a profile is Active, the button changes to a yellow caution outline with a pause symbol (bi-pause-circle), signaling a "suspend" option.

    • If a profile is Suspended, it morphs into a green play outline (bi-play-circle), inviting the admin to re-activate the account.

Filter-Aware Pagination Navigator

<a class="page-link" asp-action="Index" asp-route-page="@i" asp-route-role="@ViewBag.CurrentRole" asp-route-search="@ViewBag.Search">@i</a>
  • The Route-Sustaining Trick: This is a vital piece of navigation logic. When an administrator clicks to browse to page 2, the system must remember their active search queries. By binding asp-route-role and asp-route-search straight into the generated anchor tags, the layout appends all active query arguments to every page index step. This prevents the grid from resetting your filtered list back to the global un-filtered directory list.

In Step 4 of Part 33, we implement the core data-modification handler for our directory panel: the ToggleStatus Action Method.

This method handles state-changing requests sent by the inline forms we designed in the previous step. It locates the specific user record via their unique identity token, flips their active account status flag, commits those changes back to the SQL database using Microsoft Identity, and sends a temporary flash message to notify the admin of the change.

Step 4: Account Suspension & Activation Workflow

The State Toggle Lifecycle Pipeline

C# / Areas/Admin/Controllers/UsersController.cs (ToggleStatus Method)
[HttpPost]
public async Task<IActionResult> ToggleStatus(string id)
{
    var user = await _userManager.FindByIdAsync(id);
    if (user == null)
        return NotFound();

    user.IsActive = !user.IsActive;
    await _userManager.UpdateAsync(user);

    var status = user.IsActive ? "activated" : "deactivated";
    TempData["Success"] = $"User {status} successfully.";

    return RedirectToAction(nameof(Index));
}

Step-by-Step Code Explanation

Secure HTTP Method Restriction

[HttpPost]
public async Task<IActionResult> ToggleStatus(string id)
  • [HttpPost]: This is an essential security attribute. Since this action alters data states (freezing or unfreezing accounts), it must never accept GET requests. Restricting it to POST prevents cross-site scripting vulnerabilities, accidental search-engine bot triggers, or malicious link-clicks from altering user status.

Records Validation Check

var user = await _userManager.FindByIdAsync(id);
if (user == null)
    return NotFound();
  • The method takes the incoming id string string and executes FindByIdAsync(id).

  • If the user ID is invalid, modified by a client-side injection, or missing from the database entirely, it stops execution immediately and returns a standard 404 NotFound() HTTP response code.

Bitwise Boolean State Inversion

user.IsActive = !user.IsActive;
await _userManager.UpdateAsync(user);
  • !user.IsActive (Logical Negation Operator): Instead of writing separate methods for activation and deactivation, this single line acts as a clean, elegant toggle switch. If IsActive is currently true, it flips to false; if it is false, it flips to true.

  • _userManager.UpdateAsync(user): Instructs Microsoft Identity to build and run an optimized SQL UPDATE statement, pushing the fresh status modification directly down into your persistent user rows.

Dynamic Feedback Broadcast & View Redirection

var status = user.IsActive ? "activated" : "deactivated";
TempData["Success"] = $"User {status} successfully.";

return RedirectToAction(nameof(Index));
  • Using a quick ternary statement, it evaluates the final state to construct a precise, clean feedback string notification message (e.g., "User deactivated successfully.").

  • TempData["Success"]: Stores this message inside the short-term cookie state cache. TempData survives exactly one redirect trip across pages, making it perfect for driving top-screen toast notification bars.

  • RedirectToAction(nameof(Index)): Refreshes the browser workspace cleanly by redirecting administrators back to the main user directory table grid, showing the updated account status badge immediately.


Comments