Add Role-Based Access Control

Build a role-enforced staff management UI for a hospital system.

Introduction

30 Second Summary

In any hospital, not every staff member should have access to every system. A receptionist registering patients does not need the ability to delete other staff accounts.

In this project, you will build a role-based access control layer and staff management UI for the Korutla Hospital system. You will enforce who can do what across both the backend API and the React frontend.

What You'll Build

A complete RBAC system that seeds four roles into ASP.NET Core Identity, enforces a role hierarchy on API endpoints, includes role claims in JWT tokens, and adds a staff management page to the React frontend.

By the end of this project, you'll have:

  • Four seeded Identity roles (Admin, Doctor, Receptionist, BillingStaff) with the existing user promoted to Admin and role-enforced API endpoints backed by a CRUD UsersController.
  • A persistent Navbar component with role-conditional navigation links and a RoleRoute guard that restricts routes by role.
  • A full Staff Management page where Admins and Doctors can add, edit roles for, and delete staff members they are authorized to manage.
  • Secret Mission: Add a password reset feature so Admins and Doctors can reset passwords for staff they manage.

Are there any prerequisites?

Yes. You need to have completed the "Add Patient Billing and Invoice Printing" project, including the Secret Mission for bill persistence. Your Korutla Hospital codebase should be running locally with both the API and React frontend functional.

Seed Roles and Configure Role-Based JWT Claims

A hospital system handles sensitive patient data, so different staff members need different levels of access. An Admin should be able to manage all staff, while a Doctor should only manage Receptionists and BillingStaff.

In this step, you will seed four RBAC roles into ASP.NET Core Identity and update your JWT token generation so every token carries the user's role as a claim.

In this step, get ready to:
  • Add RBAC roles to ASP.NET Core Identity and seed them on startup.
  • Assign the Admin role to your existing user account.
  • Update JWT token generation to include role claims in every token.
Enable Identity roles and seed them on startup

ASP.NET Core Identity supports roles out of the box, but you need to explicitly enable the role services. You also need to seed the four roles your hospital hierarchy requires so they exist in the database from the first startup.

  • Open Program.cs in your HospitalApi project.
  • Find the line where ASP.NET Core Identity is configured. It looks something like this:
builder.Services.AddIdentityCore<IdentityUser>()
    .AddEntityFrameworkStores<HospitalDbContext>();
  • Add .AddRoles<IdentityRole>() and .AddDefaultTokenProviders() to the chain, so the updated version looks like this:
builder.Services.AddIdentityCore<IdentityUser>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<HospitalDbContext>()
    .AddDefaultTokenProviders();

What do these two additions do?

.AddRoles<IdentityRole>() registers the role services (like RoleManager) so you can create, query, and assign roles to users.

.AddDefaultTokenProviders() enables token generation for features like password resets. You will use this later in the project.

Now you need a block of code that runs on startup, creates the four roles if they do not already exist, and assigns your existing user the Admin role.

  • In Program.cs, find the line var app = builder.Build();.
  • Add the following role-seeding block directly below it:
// Seed RBAC roles and assign Admin to the existing user
using (var scope = app.Services.CreateScope())
{
    var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
    var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();

    // Create the four hospital roles if they don't exist yet
    string[] roles = { "Admin", "Doctor", "Receptionist", "BillingStaff" };
    foreach (var role in roles)
    {
        if (!await roleManager.RoleExistsAsync(role))
        {
            await roleManager.CreateAsync(new IdentityRole(role));
        }
    }

    // Find your existing user and promote them to Admin
    var adminUser = await userManager.FindByEmailAsync("[[USER_EMAIL="the email you registered with"]]");
    if (adminUser != null && !await userManager.IsInRoleAsync(adminUser, "Admin"))
    {
        await userManager.AddToRoleAsync(adminUser, "Admin");
    }
}

What does this seeding block do?

  • It creates a dependency injection scope so it can access RoleManager and UserManager before the app starts handling requests.
  • RoleExistsAsync checks whether each role already exists in the database. CreateAsync only runs for missing roles, so this block is safe to run on every startup.
  • FindByEmailAsync locates your existing user. IsInRoleAsync prevents a duplicate assignment if the user is already an Admin.
  • Save Program.cs.

You will not see a change yet. The migration in the final substep creates the role tables in the database.

Seeing a compile error about RoleManager or UserManager?

  • Make sure using Microsoft.AspNetCore.Identity; is at the top of Program.cs. It should already be there from when you set up Identity, but double check.
  • Confirm that .AddRoles<IdentityRole>() comes before .AddEntityFrameworkStores<HospitalDbContext>() in the Identity builder chain.

Help me fix a compile error in Program.cs after adding role seeding

✔️ Awesome, I've got everything!

Great. Make sure you have saved Program.cs before moving on.

ⓧ I'd like to double check the full code

Here are the two changes you should have made to Program.cs. Your existing code around these sections will vary, but the additions should match.

The updated Identity builder chain:

builder.Services.AddIdentityCore<IdentityUser>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<HospitalDbContext>()
    .AddDefaultTokenProviders();

The role-seeding block (placed right after var app = builder.Build();):

// Seed RBAC roles and assign Admin to the existing user
using (var scope = app.Services.CreateScope())
{
    var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
    var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();

    // Create the four hospital roles if they don't exist yet
    string[] roles = { "Admin", "Doctor", "Receptionist", "BillingStaff" };
    foreach (var role in roles)
    {
        if (!await roleManager.RoleExistsAsync(role))
        {
            await roleManager.CreateAsync(new IdentityRole(role));
        }
    }

    // Find your existing user and promote them to Admin
    var adminUser = await userManager.FindByEmailAsync("your-email@example.com");
    if (adminUser != null && !await userManager.IsInRoleAsync(adminUser, "Admin"))
    {
        await userManager.AddToRoleAsync(adminUser, "Admin");
    }
}
Add role claims to JWT token generation

Seeding roles into the database is only half the picture. For the backend to enforce roles on API requests, the user's role needs to travel inside their JWT token as a claim.

The frontend also needs to know the user's role so it can show or hide pages like Staff Management. You will update the login endpoint to embed the role in both the token and the response body.

  • Open AuthController.cs in the HospitalApi/Controllers folder.
  • Make sure using System.Security.Claims; is included in the using directives at the top of the file. It should already be present, but add it if it is missing.
  • Find the login method where the user is authenticated and the JWT token is created.
  • Right after the user is authenticated (after the password check succeeds), add the following code to retrieve the user's role:
// Get the user's role from Identity
var roles = await _userManager.GetRolesAsync(user);
var userRole = roles.FirstOrDefault() ?? "";

What does this code do?

GetRolesAsync queries the Identity database for all roles assigned to the user. Since each user in this project has exactly one role, FirstOrDefault() grabs it. The ?? "" fallback prevents a null reference if no role is assigned.

  • Find the claims list that is passed to your JWT token descriptor. It contains one or more new Claim(...) entries. Add the following claim to the list:
// Include the user's role as a claim so [Authorize(Roles = "...")] works
new Claim(ClaimTypes.Role, userRole)

Why does the role need to be a claim?

When the JWT bearer middleware reads the token on each request, it populates the user's identity from the claims list. The [Authorize(Roles = "Admin,Doctor")] attribute checks for a ClaimTypes.Role claim that matches one of the listed roles.

Without this claim in the token, every [Authorize(Roles = "...")] check would fail with a 403 Forbidden. Role names are case-sensitive, so Admin and admin are treated as different roles.

  • Find the return Ok(new { ... }) line at the end of the login method. It currently returns the token and email. Find this line:
return Ok(new { token = tokenString, email = user.Email });
  • Add role = userRole to the response object, so the line now looks like this:
return Ok(new { token = tokenString, email = user.Email, role = userRole });
  • Save AuthController.cs.

You will not see a change yet. The next substep runs the migration and verifies everything end-to-end.

Not sure where to add the role code in your login method?

  • The GetRolesAsync call needs to go after the line that checks the user's password (e.g., CheckPasswordSignInAsync or CheckPasswordAsync) and before the line that builds the JWT claims list.
  • The new Claim(ClaimTypes.Role, userRole) line goes inside the existing claims array or list, alongside your other claims.

Help me figure out where to add GetRolesAsync and the ClaimTypes.Role claim in my AuthController login method

✔️ Awesome, I've got everything!

Great. Make sure you have saved AuthController.cs before moving on.

ⓧ I'd like to double check the full code

Here are the three changes you should have made inside your login method in AuthController.cs. Your surrounding code will vary, but these additions should be present.

1. Role retrieval (after password check, before claims list):

// Get the user's role from Identity
var roles = await _userManager.GetRolesAsync(user);
var userRole = roles.FirstOrDefault() ?? "";

2. Role claim (inside the claims list/array):

new Claim(ClaimTypes.Role, userRole)

3. Updated response (returns role alongside token and email):

return Ok(new { token = tokenString, email = user.Email, role = userRole });
Run the migration and verify your role setup

Adding .AddRoles<IdentityRole>() tells Identity to expect role tables in the database. You need to create a migration so Entity Framework generates those tables.

  • In your terminal, make sure you are in the HospitalApi directory.
  • Create the migration by running this command:
dotnet ef migrations add AddIdentityRoles
  • Apply the migration to your PostgreSQL database by running:
dotnet ef database update

Seeing a migration error?

  • Make sure your terminal is in the HospitalApi directory (the folder that contains your .csproj file).
  • Confirm that your PostgreSQL server is running. The database update needs an active connection.
  • If you see a build error, fix the compile issue in Program.cs or AuthController.cs first, then re-run the migration command.

Help me fix an error when running dotnet ef migrations add AddIdentityRoles

  • Start the API by running:
dotnet run
  • Switch to your browser and open your Korutla Hospital frontend.
  • Open your browser's developer tools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab in the developer tools panel.
  • Log in with the email and password you registered with.
  • In the Network tab, click the login request and look at the Response body.

You should see a JSON response that includes "role": "Admin" alongside the token and email fields.

What should I see?

The login response JSON should now have three fields: token, email, and role. The role value is "Admin" because the seeding block assigned your user to the Admin role.

Login response missing the role field?

  • Check that the return Ok(new { ... }) line in AuthController.cs includes role = userRole.
  • If the role field is present but shows an empty string, confirm the email in your seeding block matches the email you are logging in with exactly.
  • Make sure you restarted the API after saving your changes. The seeding block runs on startup, so a restart is required for it to assign the Admin role.

Help me debug why my login response does not include the Admin role

Your RBAC foundation is in place. Every login now carries a role, and the database knows about all four hospital roles. Next up, you will build the Users API that uses this role hierarchy to control who can create, edit, and delete staff members.

Build the Users API with Role Enforcement

Your four roles are seeded and every JWT token now carries the user's role as a claim. The foundation is in place, but there is no way for an Admin or Doctor to actually create, edit, or remove staff members through the API.

In this step, you will build a Users API that enforces the RBAC hierarchy on every request. Admins can manage Doctors, Receptionists, and BillingStaff. Doctors can only manage Receptionists and BillingStaff. Every endpoint checks who is calling before deciding what to allow.

In this step, get ready to:
  • Create three DTO files for user management requests and responses.
  • Build a UsersController with four role-enforced CRUD endpoints.
  • Test the API to confirm role hierarchy enforcement.
Create the data transfer objects

Before building the controller, you need three DTO classes to define the shape of incoming requests and outgoing responses. These go in the HospitalApi/DTOs/ folder alongside your existing DTOs.

  • Create a new file called CreateUserDto.cs inside HospitalApi/DTOs/ and add this code:
using System.ComponentModel.DataAnnotations;

namespace HospitalApi.DTOs;

// Incoming request shape for creating a new staff user
public class CreateUserDto
{
    [Required]
    [EmailAddress]
    public string Email { get; set; } = string.Empty;

    [Required]
    [MinLength(6)]
    public string Password { get; set; } = string.Empty;

    // Must match one of the four Identity roles
    [Required]
    public string Role { get; set; } = string.Empty;
}

What does this code do?

This DTO defines the three fields the API expects when creating a new user: an email address, a password (minimum 6 characters), and a role name. The validation attributes like [Required] and [EmailAddress] tell ASP.NET Core to reject requests that are missing fields or have invalid email formats.

  • Create a second file called UpdateUserRoleDto.cs inside HospitalApi/DTOs/ and add this code:
using System.ComponentModel.DataAnnotations;

namespace HospitalApi.DTOs;

// Incoming request shape for changing a user's role
public class UpdateUserRoleDto
{
    [Required]
    public string Role { get; set; } = string.Empty;
}

What does this code do?

This DTO only carries a single Role field. When an Admin changes a Doctor to a Receptionist, the API only needs the new role name.

  • Create a third file called UserResponseDto.cs inside HospitalApi/DTOs/ and add this code:
namespace HospitalApi.DTOs;

// Outgoing response shape returned by the Users API
public class UserResponseDto
{
    public string Id { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Role { get; set; } = string.Empty;
}

What does this code do?

This DTO shapes what the API sends back to the frontend. It includes the user's ID, email, and role. Unlike the create DTO, it has no validation attributes because it is outgoing data, not incoming input.

  • Save all three files.
  • Confirm the files exist by running this from your HospitalApi directory:
ls DTOs/

You should see CreateUserDto.cs, UpdateUserRoleDto.cs, and UserResponseDto.cs listed alongside your existing DTO files.

Missing a DTO file?

  • Check that you saved each file inside HospitalApi/DTOs/, not in the project root or a different folder.
  • Make sure the filenames match exactly, including the capital letters (e.g. CreateUserDto.cs, not createuserdto.cs).
  • help me find where my DTO files ended up
Build the UsersController with role hierarchy

The controller is where the RBAC hierarchy becomes real. A ManageableRoles dictionary maps each caller role to the roles it can manage. Every endpoint checks this dictionary before allowing the operation.

  • Create a new file called UsersController.cs inside HospitalApi/Controllers/ and add the class structure by pasting this code:
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using HospitalApi.DTOs;

namespace HospitalApi.Controllers;

[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin,Doctor")]
public class UsersController : ControllerBase
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly RoleManager<IdentityRole> _roleManager;

    // Maps each caller role to the roles it is allowed to manage
    private static readonly Dictionary<string, string[]> ManageableRoles = new()
    {
        { "Admin", new[] { "Doctor", "Receptionist", "BillingStaff" } },
        { "Doctor", new[] { "Receptionist", "BillingStaff" } }
    };

    public UsersController(UserManager<IdentityUser> userManager, RoleManager<IdentityRole> roleManager)
    {
        _userManager = userManager;
        _roleManager = roleManager;
    }

    // CRUD endpoints will be added below this line

    // Reads the caller's role from the JWT claims
    private string GetCallerRole()
    {
        return User.FindFirstValue(ClaimTypes.Role) ?? "";
    }

    // Checks whether callerRole is allowed to manage targetRole
    private bool CanManageRole(string callerRole, string targetRole)
    {
        if (!ManageableRoles.ContainsKey(callerRole)) return false;
        return ManageableRoles[callerRole].Contains(targetRole);
    }
}

What does this code do?

  • [Authorize(Roles = "Admin,Doctor")] at the class level blocks any user who is not an Admin or Doctor from reaching any endpoint in this controller.
  • ManageableRoles is a dictionary that encodes the hierarchy. An Admin can manage three roles. A Doctor can manage two. Receptionists and BillingStaff cannot manage anyone.
  • GetCallerRole() reads the role claim you added to the JWT in Step 1. CanManageRole() checks the dictionary to decide if the caller is allowed to act on a target role.

Why a dictionary instead of a numeric rank?

In this hierarchy, Receptionist and BillingStaff sit at the same level. A strict numeric ranking (Admin=3, Doctor=2, Receptionist=1, BillingStaff=0) would incorrectly imply that Receptionist outranks BillingStaff. An explicit dictionary avoids that problem and makes the hierarchy easy to extend later.

  • Save UsersController.cs.

You will not see a change yet. The next chunk adds the actual endpoints that use this structure.

  • In UsersController.cs, find the comment // CRUD endpoints will be added below this line and replace it with the following code:
    // Lists all users whose roles the caller is allowed to manage
    [HttpGet]
    public async Task<IActionResult> GetUsers()
    {
        var callerRole = GetCallerRole();
        if (!ManageableRoles.ContainsKey(callerRole))
            return Forbid();

        var allowedRoles = ManageableRoles[callerRole];
        var result = new List<UserResponseDto>();

        // Loop through each manageable role and collect matching users
        foreach (var role in allowedRoles)
        {
            var usersInRole = await _userManager.GetUsersInRoleAsync(role);
            foreach (var u in usersInRole)
            {
                // Avoid duplicate entries if a user appears in multiple roles
                if (!result.Any(r => r.Id == u.Id))
                {
                    result.Add(new UserResponseDto
                    {
                        Id = u.Id,
                        Email = u.Email ?? "",
                        Role = role
                    });
                }
            }
        }

        return Ok(result.OrderBy(u => u.Email));
    }

    // Creates a new user and assigns them to the specified role
    [HttpPost]
    public async Task<IActionResult> CreateUser([FromBody] CreateUserDto dto)
    {
        var callerRole = GetCallerRole();
        // Block the request if the caller cannot assign this role
        if (!CanManageRole(callerRole, dto.Role))
            return StatusCode(403, new { message = $"You cannot assign the {dto.Role} role." });

        var user = new IdentityUser { UserName = dto.Email, Email = dto.Email };
        var createResult = await _userManager.CreateAsync(user, dto.Password);

        if (!createResult.Succeeded)
        {
            var errors = string.Join(" ", createResult.Errors.Select(e => e.Description));
            return BadRequest(new { message = errors });
        }

        await _userManager.AddToRoleAsync(user, dto.Role);

        return CreatedAtAction(nameof(GetUsers), null, new UserResponseDto
        {
            Id = user.Id,
            Email = user.Email ?? "",
            Role = dto.Role
        });
    }

What do these endpoints do?

  • GET /api/users loops through every role the caller can manage and collects those users into a list. An Admin sees Doctors, Receptionists, and BillingStaff. A Doctor only sees Receptionists and BillingStaff.
  • POST /api/users creates a new user with CreateAsync and assigns the requested role with AddToRoleAsync. If the caller tries to assign a role they cannot manage, the endpoint returns a 403.
  • Save UsersController.cs.

You will not see a change yet. The next chunk adds the remaining two endpoints.

  • Still in UsersController.cs, add the following code directly below the CreateUser method (above the GetCallerRole helper):
    // Changes a user's role after verifying the caller can manage both old and new roles
    [HttpPut("{id}/role")]
    public async Task<IActionResult> UpdateUserRole(string id, [FromBody] UpdateUserRoleDto dto)
    {
        var callerRole = GetCallerRole();
        var user = await _userManager.FindByIdAsync(id);
        if (user == null) return NotFound(new { message = "User not found." });

        var currentRoles = await _userManager.GetRolesAsync(user);
        var currentRole = currentRoles.FirstOrDefault() ?? "";

        // Caller must be able to manage the user's current role
        if (!CanManageRole(callerRole, currentRole))
            return StatusCode(403, new { message = "You cannot modify this user." });

        // Caller must also be able to assign the new role
        if (!CanManageRole(callerRole, dto.Role))
            return StatusCode(403, new { message = $"You cannot assign the {dto.Role} role." });

        // Remove old roles before assigning the new one
        if (currentRoles.Count > 0)
            await _userManager.RemoveFromRolesAsync(user, currentRoles);

        await _userManager.AddToRoleAsync(user, dto.Role);

        return Ok(new UserResponseDto
        {
            Id = user.Id,
            Email = user.Email ?? "",
            Role = dto.Role
        });
    }

    // Deletes a user after verifying the caller can manage that user's role
    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteUser(string id)
    {
        var callerRole = GetCallerRole();
        var callerId = User.FindFirstValue(ClaimTypes.NameIdentifier);

        // Prevent users from deleting themselves
        if (id == callerId)
            return BadRequest(new { message = "You cannot delete yourself." });

        var user = await _userManager.FindByIdAsync(id);
        if (user == null) return NotFound(new { message = "User not found." });

        var roles = await _userManager.GetRolesAsync(user);
        var targetRole = roles.FirstOrDefault() ?? "";

        if (!CanManageRole(callerRole, targetRole))
            return StatusCode(403, new { message = "You cannot delete this user." });

        var result = await _userManager.DeleteAsync(user);
        if (!result.Succeeded)
        {
            var errors = string.Join(" ", result.Errors.Select(e => e.Description));
            return BadRequest(new { message = errors });
        }

        return NoContent();
    }

What do these endpoints do?

  • PUT /api/users/{id}/role performs a double check. It verifies the caller can manage the user's current role AND the new role being assigned. This prevents a Doctor from promoting a Receptionist to Admin.
  • DELETE /api/users/{id} removes a user from the database entirely. It has a self-deletion guard that compares the target ID against the caller's own ClaimTypes.NameIdentifier to stop an Admin from accidentally removing themselves.
  • Save UsersController.cs.

Your controller file is now complete with all four CRUD endpoints and two helper methods.

Seeing red squiggles in UsersController.cs?

  • Make sure the using HospitalApi.DTOs; import is at the top of the file. Without it, the DTO types will not resolve.
  • Check that you placed the GET, POST, PUT, and DELETE methods inside the class braces, between the constructor and the GetCallerRole helper method.
  • Confirm the namespace in each DTO file is HospitalApi.DTOs. A mismatched namespace will cause the controller to fail to find the DTO classes.
  • help me fix compile errors in my UsersController

✔️ Awesome, I've got everything!

Great. Double check you have saved UsersController.cs before moving on.

ⓧ I'd like to double check the full code

Here is the complete UsersController.cs file for comparison.

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using HospitalApi.DTOs;

namespace HospitalApi.Controllers;

[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin,Doctor")]
public class UsersController : ControllerBase
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly RoleManager<IdentityRole> _roleManager;

    private static readonly Dictionary<string, string[]> ManageableRoles = new()
    {
        { "Admin", new[] { "Doctor", "Receptionist", "BillingStaff" } },
        { "Doctor", new[] { "Receptionist", "BillingStaff" } }
    };

    public UsersController(UserManager<IdentityUser> userManager, RoleManager<IdentityRole> roleManager)
    {
        _userManager = userManager;
        _roleManager = roleManager;
    }

    [HttpGet]
    public async Task<IActionResult> GetUsers()
    {
        var callerRole = GetCallerRole();
        if (!ManageableRoles.ContainsKey(callerRole))
            return Forbid();

        var allowedRoles = ManageableRoles[callerRole];
        var result = new List<UserResponseDto>();

        foreach (var role in allowedRoles)
        {
            var usersInRole = await _userManager.GetUsersInRoleAsync(role);
            foreach (var u in usersInRole)
            {
                if (!result.Any(r => r.Id == u.Id))
                {
                    result.Add(new UserResponseDto
                    {
                        Id = u.Id,
                        Email = u.Email ?? "",
                        Role = role
                    });
                }
            }
        }

        return Ok(result.OrderBy(u => u.Email));
    }

    [HttpPost]
    public async Task<IActionResult> CreateUser([FromBody] CreateUserDto dto)
    {
        var callerRole = GetCallerRole();
        if (!CanManageRole(callerRole, dto.Role))
            return StatusCode(403, new { message = $"You cannot assign the {dto.Role} role." });

        var user = new IdentityUser { UserName = dto.Email, Email = dto.Email };
        var createResult = await _userManager.CreateAsync(user, dto.Password);

        if (!createResult.Succeeded)
        {
            var errors = string.Join(" ", createResult.Errors.Select(e => e.Description));
            return BadRequest(new { message = errors });
        }

        await _userManager.AddToRoleAsync(user, dto.Role);

        return CreatedAtAction(nameof(GetUsers), null, new UserResponseDto
        {
            Id = user.Id,
            Email = user.Email ?? "",
            Role = dto.Role
        });
    }

    [HttpPut("{id}/role")]
    public async Task<IActionResult> UpdateUserRole(string id, [FromBody] UpdateUserRoleDto dto)
    {
        var callerRole = GetCallerRole();
        var user = await _userManager.FindByIdAsync(id);
        if (user == null) return NotFound(new { message = "User not found." });

        var currentRoles = await _userManager.GetRolesAsync(user);
        var currentRole = currentRoles.FirstOrDefault() ?? "";

        if (!CanManageRole(callerRole, currentRole))
            return StatusCode(403, new { message = "You cannot modify this user." });

        if (!CanManageRole(callerRole, dto.Role))
            return StatusCode(403, new { message = $"You cannot assign the {dto.Role} role." });

        if (currentRoles.Count > 0)
            await _userManager.RemoveFromRolesAsync(user, currentRoles);

        await _userManager.AddToRoleAsync(user, dto.Role);

        return Ok(new UserResponseDto
        {
            Id = user.Id,
            Email = user.Email ?? "",
            Role = dto.Role
        });
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteUser(string id)
    {
        var callerRole = GetCallerRole();
        var callerId = User.FindFirstValue(ClaimTypes.NameIdentifier);

        if (id == callerId)
            return BadRequest(new { message = "You cannot delete yourself." });

        var user = await _userManager.FindByIdAsync(id);
        if (user == null) return NotFound(new { message = "User not found." });

        var roles = await _userManager.GetRolesAsync(user);
        var targetRole = roles.FirstOrDefault() ?? "";

        if (!CanManageRole(callerRole, targetRole))
            return StatusCode(403, new { message = "You cannot delete this user." });

        var result = await _userManager.DeleteAsync(user);
        if (!result.Succeeded)
        {
            var errors = string.Join(" ", result.Errors.Select(e => e.Description));
            return BadRequest(new { message = errors });
        }

        return NoContent();
    }

    private string GetCallerRole()
    {
        return User.FindFirstValue(ClaimTypes.Role) ?? "";
    }

    private bool CanManageRole(string callerRole, string targetRole)
    {
        if (!ManageableRoles.ContainsKey(callerRole)) return false;
        return ManageableRoles[callerRole].Contains(targetRole);
    }
}
Restart the API and test the endpoints

The controller is written, but you need to confirm it compiles and the role enforcement actually works. You will log in as Admin, call the GET endpoint, create a test user, and verify that creating an Admin user is blocked.

  • If the API is still running, stop it with Ctrl+C in the terminal.
  • Restart the API from the HospitalApi directory by running:
dotnet run
  • Switch to your browser and log in through the frontend to get a fresh JWT token that includes your Admin role claim.
  • Open your browser's developer tools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Console tab.
  • Test the GET endpoint by pasting this into the console and pressing Enter:
// Reads the JWT token from localStorage and calls GET /api/users
const token = JSON.parse(localStorage.getItem("user")).token;
fetch("/api/users", { headers: { "Authorization": `Bearer ${token}` } })
  .then(r => r.json()).then(console.log);

You should see an empty array [] in the console because no managed users exist yet.

  • Create a test Doctor user by pasting this into the console:
// Creates a new Doctor user via POST /api/users
const token2 = JSON.parse(localStorage.getItem("user")).token;
fetch("/api/users", {
  method: "POST",
  headers: { "Authorization": `Bearer ${token2}`, "Content-Type": "application/json" },
  body: JSON.stringify({ email: "doctor@test.com", password: "Test123!", role: "Doctor" })
}).then(r => r.json()).then(console.log);

You should see a JSON response containing the new user's id, email, and role fields.

  • Now verify that role enforcement blocks unauthorized role assignments. Paste this into the console:
// Attempts to create an Admin user, which should be blocked
const token3 = JSON.parse(localStorage.getItem("user")).token;
fetch("/api/users", {
  method: "POST",
  headers: { "Authorization": `Bearer ${token3}`, "Content-Type": "application/json" },
  body: JSON.stringify({ email: "rogue@test.com", password: "Test123!", role: "Admin" })
}).then(r => r.json()).then(console.log);

You should see a 403 response with the message You cannot assign the Admin role. confirming that even an Admin cannot create another Admin through this API.

Why can't an Admin create another Admin?

The ManageableRoles dictionary maps Admin to Doctor, Receptionist, and BillingStaff. Admin is not in its own manageable list. This is intentional. In a hospital system, Admin accounts should be provisioned through a controlled process, not through the everyday staff management UI.

Getting a 401 Unauthorized or a blank response?

  • Make sure you logged in through the frontend first. The console scripts read the JWT token from localStorage, which is only populated after login.
  • If the token has expired, log out and log back in to get a fresh one.
  • Confirm the API is running. Check the terminal where you ran dotnet run for any compile errors.
  • help me debug my Users API returning 401

Your Users API is locked down and enforcing the role hierarchy on every request. Next up, you will build the frontend navigation and route guards to match.

Add Role-Based Routing and Navigation

Your backend now enforces who can manage whom through the Users API. The frontend needs to match that enforcement so users only see pages and links that their role permits.

In this step, you will build a persistent navigation bar that shows role-appropriate links, add a route guard that blocks unauthorized users from the Staff Management page, and clean up the old per-page header from the Patients page.

In this step, get ready to:
  • Create a Navbar component with role-conditional links.
  • Add a RoleRoute guard and a /staff route to App.tsx.
  • Remove the old header from PatientsPage and verify the Navbar across all pages.
Create the Navbar component

Right now, each page handles its own user display and logout button independently. A shared navigation bar centralizes this into one component that appears on every page after login.

  • In VS Code, create a new file called Navbar.tsx inside the hospital-frontend/src/components/ folder.
  • Paste the following code into Navbar.tsx:
import { Link } from "react-router";
import { useAuth } from "../context/AuthContext";

export default function Navbar() {
  const { user, logout } = useAuth();
  // Don't render the navbar if no user is logged in
  if (!user) return null;

  return (
    <nav
      style={{
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        padding: "12px 20px",
        backgroundColor: "#1a1a2e",
        color: "#fff",
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 24 }}>
        {/* Hospital name links back to the patients list */}
        <Link
          to="/patients"
          style={{
            color: "#fff",
            textDecoration: "none",
            fontWeight: "bold",
            fontSize: 18,
          }}
        >
          Korutla Hospital
        </Link>
        <Link
          to="/patients"
          style={{ color: "#ccc", textDecoration: "none" }}
        >
          Patients
        </Link>
        {/* Only Admin and Doctor roles can see the Staff Management link */}
        {(user.role === "Admin" || user.role === "Doctor") && (
          <Link
            to="/staff"
            style={{ color: "#ccc", textDecoration: "none" }}
          >
            Staff Management
          </Link>
        )}
      </div>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        {/* Show the logged-in user's email and role */}
        <span>
          {user.email} ({user.role})
        </span>
        <button
          onClick={logout}
          style={{
            padding: "6px 12px",
            backgroundColor: "#e74c3c",
            color: "#fff",
            border: "none",
            borderRadius: 4,
            cursor: "pointer",
          }}
        >
          Logout
        </button>
      </div>
    </nav>
  );
}

What does this component do?

The Navbar pulls the current user and logout function from useAuth. If no user is logged in, it returns null so nothing renders.

The left side shows the hospital name and navigation links. The "Staff Management" link only appears when the user's role is Admin or Doctor. The right side displays the user's email, role, and a logout button.

  • Save Navbar.tsx.

You won't see the Navbar in the browser yet. The next substep wires it into App.tsx so it renders on every page.

Seeing a red underline on the import?

If VS Code shows an error on the useAuth import, make sure the file is saved inside src/components/, not src/pages/. The relative path ../context/AuthContext depends on the file being one level inside src/.

Help me debug my Navbar.tsx import error

✔️ Awesome, I've got everything!

Great. Make sure you have saved Navbar.tsx before moving on.

ⓧ I'd like to double check the full code

Here is the full Navbar.tsx file for comparison.

import { Link } from "react-router";
import { useAuth } from "../context/AuthContext";

export default function Navbar() {
  const { user, logout } = useAuth();
  if (!user) return null;

  return (
    <nav
      style={{
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        padding: "12px 20px",
        backgroundColor: "#1a1a2e",
        color: "#fff",
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 24 }}>
        <Link
          to="/patients"
          style={{
            color: "#fff",
            textDecoration: "none",
            fontWeight: "bold",
            fontSize: 18,
          }}
        >
          Korutla Hospital
        </Link>
        <Link
          to="/patients"
          style={{ color: "#ccc", textDecoration: "none" }}
        >
          Patients
        </Link>
        {(user.role === "Admin" || user.role === "Doctor") && (
          <Link
            to="/staff"
            style={{ color: "#ccc", textDecoration: "none" }}
          >
            Staff Management
          </Link>
        )}
      </div>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <span>
          {user.email} ({user.role})
        </span>
        <button
          onClick={logout}
          style={{
            padding: "6px 12px",
            backgroundColor: "#e74c3c",
            color: "#fff",
            border: "none",
            borderRadius: 4,
            cursor: "pointer",
          }}
        >
          Logout
        </button>
      </div>
    </nav>
  );
}
Add the RoleRoute guard and wire up routing in App.tsx

The existing ProtectedRoute component only checks if a user is logged in. For the Staff Management page, you also need to check the user's role. A new RoleRoute component handles both checks.

  • In App.tsx, replace the imports at the top of the file with these updated imports:
import { BrowserRouter, Routes, Route, Navigate } from "react-router";
import { AuthProvider, useAuth } from "./context/AuthContext";
import LoginPage from "./pages/LoginPage";
import PatientsPage from "./pages/PatientsPage";
import RegisterPatientPage from "./pages/RegisterPatientPage";
import BillingPage from "./pages/BillingPage";
// New imports for staff management and navigation
import StaffPage from "./pages/StaffPage";
import Navbar from "./components/Navbar";
import { ReactNode } from "react";

What are the new imports?

  • StaffPage is the page component you will build in the next step.
  • Navbar is the navigation bar you just created.
  • ReactNode is a TypeScript type that represents any valid React child element. The existing ProtectedRoute already uses it, and the new RoleRoute needs it too.
  • Below the existing ProtectedRoute function, add the RoleRoute component by pasting this code:
// Guards routes that require a specific role
function RoleRoute({
  children,
  roles,
}: {
  children: ReactNode;
  roles: string[];
}) {
  const { user } = useAuth();
  // Redirect to login if no user is authenticated
  if (!user) return <Navigate to="/login" />;
  // Redirect to patients page if user's role is not in the allowed list
  if (!roles.includes(user.role)) return <Navigate to="/patients" />;
  return children;
}

How does RoleRoute differ from ProtectedRoute?

ProtectedRoute only checks if a user is logged in. RoleRoute does two checks: first, is the user authenticated (redirects to /login if not), and second, is the user's role in the allowed list (redirects to /patients if not).

The roles prop accepts an array of allowed role names, making the guard reusable for different pages with different role requirements.

Now you need to update the AppRoutes function to render the Navbar and add the /staff route.

  • Replace the existing AppRoutes function with this updated version:
function AppRoutes() {
  const { user } = useAuth();
  return (
    <>
      {/* Render the Navbar on every page when logged in */}
      {user && <Navbar />}
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route
          path="/patients"
          element={
            <ProtectedRoute>
              <PatientsPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/patients/new"
          element={
            <ProtectedRoute>
              <RegisterPatientPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/patients/:id/billing"
          element={
            <ProtectedRoute>
              <BillingPage />
            </ProtectedRoute>
          }
        />
        {/* Staff route restricted to Admin and Doctor roles */}
        <Route
          path="/staff"
          element={
            <RoleRoute roles={["Admin", "Doctor"]}>
              <StaffPage />
            </RoleRoute>
          }
        />
        <Route path="*" element={<Navigate to="/login" />} />
      </Routes>
    </>
  );
}

What changed in AppRoutes?

  • {user && <Navbar />} renders the Navbar above all routes, but only when a user is logged in. On the login page, user is null so the Navbar stays hidden.
  • The /staff route uses RoleRoute instead of ProtectedRoute. Only users with the Admin or Doctor role can access it.
  • Save App.tsx.

You may see a compile error in the terminal because StaffPage does not exist yet. That is expected and will be resolved in the next step when you create the file.

Seeing unexpected errors beyond the missing StaffPage?

Check that the RoleRoute function is placed after ProtectedRoute and before AppRoutes. Functions need to be declared before they are used inside the same file.

Make sure the import Navbar from "./components/Navbar" path matches the folder where you created the file. The path is case-sensitive.

Help me fix errors in my App.tsx after adding RoleRoute and Navbar

✔️ Awesome, I've got everything!

Great. Make sure App.tsx is saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete App.tsx file for comparison.

import { BrowserRouter, Routes, Route, Navigate } from "react-router";
import { AuthProvider, useAuth } from "./context/AuthContext";
import LoginPage from "./pages/LoginPage";
import PatientsPage from "./pages/PatientsPage";
import RegisterPatientPage from "./pages/RegisterPatientPage";
import BillingPage from "./pages/BillingPage";
import StaffPage from "./pages/StaffPage";
import Navbar from "./components/Navbar";
import { ReactNode } from "react";

function ProtectedRoute({ children }: { children: ReactNode }) {
  const { user } = useAuth();
  if (!user) {
    return <Navigate to="/login" />;
  }
  return children;
}

function RoleRoute({
  children,
  roles,
}: {
  children: ReactNode;
  roles: string[];
}) {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" />;
  if (!roles.includes(user.role)) return <Navigate to="/patients" />;
  return children;
}

function AppRoutes() {
  const { user } = useAuth();
  return (
    <>
      {user && <Navbar />}
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route
          path="/patients"
          element={
            <ProtectedRoute>
              <PatientsPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/patients/new"
          element={
            <ProtectedRoute>
              <RegisterPatientPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/patients/:id/billing"
          element={
            <ProtectedRoute>
              <BillingPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/staff"
          element={
            <RoleRoute roles={["Admin", "Doctor"]}>
              <StaffPage />
            </RoleRoute>
          }
        />
        <Route path="*" element={<Navigate to="/login" />} />
      </Routes>
    </>
  );
}

export default function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <AppRoutes />
      </AuthProvider>
    </BrowserRouter>
  );
}
Remove the old header from PatientsPage and verify

The Patients page currently has its own header showing the user's email, role, and a logout button. The Navbar now handles all of this globally, so the old header needs to be removed.

  • Open PatientsPage.tsx in VS Code.
  • Find the useAuth import near the top of the file and delete that import line.
  • Find the header <div> in the JSX that displays the user's email, role, and a logout button. Delete the entire block, including the opening and closing <div> tags and everything inside them.
  • Also remove the line const { user, logout } = useAuth(); (or similar) from inside the component function, since the page no longer needs it.

Why remove the header?

Having user info and logout in both the Navbar and the page itself would show duplicate controls. Removing the per-page header keeps the UI clean and ensures user actions live in one consistent place.

  • Save PatientsPage.tsx.

To test the Navbar and routing, you need to temporarily create an empty StaffPage.tsx placeholder so the app compiles.

  • Create a new file called StaffPage.tsx inside hospital-frontend/src/pages/.
  • Add this temporary placeholder so the import resolves:
// Temporary placeholder - you will replace this in the next step
export default function StaffPage() {
  return <div style={{ padding: 20 }}><h1>Staff Management</h1></div>;
}
  • Save StaffPage.tsx.
  • Switch to your browser and log in if you are not already logged in.
  • You should see a dark navigation bar at the top of the page with "Korutla Hospital", "Patients", and "Staff Management" links on the left, and your email, role, and a red Logout button on the right.
  • Click Staff Management in the Navbar. You should see the placeholder "Staff Management" heading.
  • Click Patients to navigate back. Confirm the old user header is gone and only the Navbar shows your user info.

Navbar not showing or page is blank?

If the page is blank, open your browser's developer console (press F12 or Cmd+Option+I on macOS) and check for error messages. A common cause is a missing or misspelled import in App.tsx.

If the Navbar appears but the Staff Management link is missing, make sure you are logged in as an Admin user. Only Admin and Doctor roles see that link.

If the old user header still appears on the Patients page, double check that you removed both the useAuth import and the header <div> block from PatientsPage.tsx.

Help me debug why my Navbar is not showing or the Staff Management link is missing

✔️ Awesome, I've got everything!

Great. Confirm that all three files are saved before continuing.

ⓧ I'd like to double check the full code

Here is the complete PatientsPage.tsx file after the cleanup.

import { useEffect, useState } from "react";
import { Link } from "react-router";
import axiosClient from "../api/axiosClient";

interface Patient {
  id: number;
  patientUniqueId: string;
  firstName: string;
  lastName: string;
  dateOfBirth: string;
  gender: string;
  phoneNumber: string;
  patientType: string;
  registeredAt: string;
}

export default function PatientsPage() {
  const [patients, setPatients] = useState<Patient[]>([]);

  useEffect(() => {
    axiosClient.get("/api/patients").then((res) => setPatients(res.data));
  }, []);

  return (
    <div style={{ padding: 20 }}>
      <h1>Patients</h1>
      <Link to="/patients/new">
        <button style={{ marginBottom: 16, padding: "8px 16px" }}>
          Register New Patient
        </button>
      </Link>
      <table style={{ width: "100%", borderCollapse: "collapse" }}>
        <thead>
          <tr>
            <th style={thStyle}>Patient ID</th>
            <th style={thStyle}>Name</th>
            <th style={thStyle}>Gender</th>
            <th style={thStyle}>Phone</th>
            <th style={thStyle}>Type</th>
            <th style={thStyle}>Registered</th>
          </tr>
        </thead>
        <tbody>
          {patients.map((p) => (
            <tr key={p.id}>
              <td style={tdStyle}>
                <Link
                  to={`/patients/${p.id}/billing`}
                  style={{ color: "#0066cc" }}
                >
                  {p.patientUniqueId}
                </Link>
              </td>
              <td style={tdStyle}>
                {p.firstName} {p.lastName}
              </td>
              <td style={tdStyle}>{p.gender}</td>
              <td style={tdStyle}>{p.phoneNumber}</td>
              <td style={tdStyle}>{p.patientType}</td>
              <td style={tdStyle}>
                {new Date(p.registeredAt).toLocaleDateString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

const thStyle = {
  borderBottom: "2px solid #ccc",
  textAlign: "left" as const,
  padding: 8,
};

const tdStyle = {
  borderBottom: "1px solid #eee",
  padding: 8,
};

Your navigation bar and route guard are working. Next up, you will replace the placeholder Staff Management page with the full staff list, add form, and role editing controls.

Build the Staff Management Page

Your backend API is ready and the Navbar is routing users to the Staff Management page. The page itself is currently empty.

In this step, you will build the full Staff Management page. It needs a table that lists staff members, a form for adding new staff, inline role editing, and a delete button with confirmation.

In this step, get ready to:
  • Build a staff list table with role-colored badges and data fetching.
  • Add a collapsible form for creating new staff members with role-filtered dropdown options.
  • Wire up inline role editing and deletion with end-to-end testing.
Create the staff page with data fetching and table display

The Staff Management page needs to fetch the list of managed users from the API on load and display them in a table. Each row shows the staff member's email, their role as a colored badge, and action buttons.

  • In VS Code, create a new file called StaffPage.tsx inside src/pages/ in your hospital-frontend folder.
  • Start by pasting the imports, data type, and role configuration at the top of the file:
import { useEffect, useState } from "react";
import axiosClient from "../api/axiosClient";
import { useAuth } from "../context/AuthContext";

// Shape of each staff member returned by the API
interface StaffMember {
  id: string;
  email: string;
  role: string;
}

// Maps each caller role to the roles they can assign
const ROLE_OPTIONS: Record<string, string[]> = {
  Admin: ["Doctor", "Receptionist", "BillingStaff"],
  Doctor: ["Receptionist", "BillingStaff"],
};

What does this code do?

The StaffMember interface matches the shape of the UserResponseDto your backend returns.

ROLE_OPTIONS mirrors the ManageableRoles dictionary from your UsersController. An Admin sees Doctor, Receptionist, and BillingStaff in dropdowns. A Doctor only sees Receptionist and BillingStaff.

  • Below the ROLE_OPTIONS constant, add the component function with all state variables and the data-fetching logic:
export default function StaffPage() {
  const { user } = useAuth();
  // Staff list and loading state
  const [staff, setStaff] = useState<StaffMember[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  // Add-staff form state
  const [showForm, setShowForm] = useState(false);
  const [newEmail, setNewEmail] = useState("");
  const [newPassword, setNewPassword] = useState("");
  const [newRole, setNewRole] = useState("");
  const [saving, setSaving] = useState(false);

  // Inline role-editing state
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editRole, setEditRole] = useState("");

  // Filter role options based on the current user's role
  const allowedRoles = user ? ROLE_OPTIONS[user.role] || [] : [];

  // Fetch the list of staff the current user can manage
  const fetchStaff = async () => {
    try {
      const res = await axiosClient.get("/api/users");
      setStaff(res.data);
      setError("");
    } catch {
      setError("Failed to load staff.");
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchStaff();
  }, []);

What does this code do?

The component declares three groups of state. staff, loading, and error track the fetched staff list. The showForm group controls the add-staff form. The editingId group tracks which row is being edited inline.

fetchStaff calls GET /api/users through axiosClient, which automatically attaches your JWT token. The useEffect hook triggers this fetch once when the page loads.

You won't see a change yet. The next chunk adds the event handlers and the full JSX that renders the page.

Add the staff creation form and event handlers

The page needs three event handlers: one for creating staff, one for updating a role, and one for deleting a staff member. Each handler calls the corresponding API endpoint, then refreshes the staff list.

  • Below the useEffect block, add the handleCreate function:
  const handleCreate = async () => {
    // Validate all fields are filled
    if (!newEmail || !newPassword || !newRole) {
      setError("All fields are required.");
      return;
    }
    setSaving(true);
    try {
      // POST the new staff member to the API
      await axiosClient.post("/api/users", {
        email: newEmail,
        password: newPassword,
        role: newRole,
      });
      // Reset the form fields and hide the form
      setNewEmail("");
      setNewPassword("");
      setNewRole("");
      setShowForm(false);
      setError("");
      await fetchStaff();
    } catch (err: any) {
      setError(err.response?.data?.message || "Failed to create staff.");
    } finally {
      setSaving(false);
    }
  };

What does this code do?

handleCreate validates that all three form fields are filled, then POSTs to /api/users. On success, it clears the form fields, hides the form, and calls fetchStaff() to refresh the table.

If the API returns an error (like a duplicate email or a weak password), the catch block extracts the message from the response and displays it.

  • Below handleCreate, add the handleUpdateRole and handleDelete functions:
  const handleUpdateRole = async (id: string) => {
    try {
      // PUT the new role to the API
      await axiosClient.put(`/api/users/${id}/role`, { role: editRole });
      setEditingId(null);
      setEditRole("");
      setError("");
      await fetchStaff();
    } catch (err: any) {
      setError(err.response?.data?.message || "Failed to update role.");
    }
  };

  const handleDelete = async (id: string, email: string) => {
    // Show a confirmation dialog before deleting
    if (!window.confirm(`Delete staff member ${email}? This cannot be undone.`))
      return;
    try {
      await axiosClient.delete(`/api/users/${id}`);
      setError("");
      await fetchStaff();
    } catch (err: any) {
      setError(err.response?.data?.message || "Failed to delete staff.");
    }
  };

What does this code do?

handleUpdateRole sends a PUT request to /api/users/{id}/role with the new role. After a successful update, it resets the editing state and refreshes the list.

handleDelete triggers a window.confirm dialog so the user must confirm before the DELETE request fires. This prevents accidental staff removal.

  • Below handleDelete, add the loading check and the full JSX return block that renders the page layout, add-staff form, and staff table:
  if (loading) return <div style={{ padding: 20 }}>Loading staff...</div>;

  return (
    <div style={{ padding: 20 }}>
      {/* Page header with title and Add Staff toggle */}
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 20,
        }}
      >
        <h1>Staff Management</h1>
        <button
          onClick={() => setShowForm(!showForm)}
          style={{ padding: "8px 16px" }}
        >
          {showForm ? "Cancel" : "Add Staff"}
        </button>
      </div>

      {/* Error message display */}
      {error && <p style={{ color: "red", marginBottom: 12 }}>{error}</p>}

      {/* Collapsible add-staff form */}
      {showForm && (
        <div
          style={{
            border: "1px solid #ccc",
            padding: 16,
            marginBottom: 24,
            borderRadius: 4,
          }}
        >
          <h3 style={{ marginTop: 0 }}>Add New Staff</h3>
          <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
            <input
              placeholder="Email"
              value={newEmail}
              onChange={(e) => setNewEmail(e.target.value)}
              style={{ padding: 8, flex: 1, minWidth: 200 }}
            />
            <input
              type="password"
              placeholder="Password"
              value={newPassword}
              onChange={(e) => setNewPassword(e.target.value)}
              style={{ padding: 8, flex: 1, minWidth: 200 }}
            />
            {/* Role dropdown filtered by the caller's allowed roles */}
            <select
              value={newRole}
              onChange={(e) => setNewRole(e.target.value)}
              style={{ padding: 8, minWidth: 150 }}
            >
              <option value="">Select Role</option>
              {allowedRoles.map((r) => (
                <option key={r} value={r}>
                  {r}
                </option>
              ))}
            </select>
            <button
              onClick={handleCreate}
              disabled={saving}
              style={{ padding: "8px 16px" }}
            >
              {saving ? "Creating..." : "Create"}
            </button>
          </div>
        </div>
      )}

      {/* Staff table */}
      <table style={{ width: "100%", borderCollapse: "collapse" }}>
        <thead>
          <tr>
            <th style={thStyle}>Email</th>
            <th style={thStyle}>Role</th>
            <th style={thStyle}>Actions</th>
          </tr>
        </thead>
        <tbody>
          {staff.length === 0 ? (
            <tr>
              <td
                colSpan={3}
                style={{ ...tdStyle, textAlign: "center", color: "#888" }}
              >
                No staff members found.
              </td>
            </tr>
          ) : (
            staff.map((s) => (
              <tr key={s.id}>
                <td style={tdStyle}>{s.email}</td>
                <td style={tdStyle}>
                  {/* Show dropdown when editing, badge when not */}
                  {editingId === s.id ? (
                    <div style={{ display: "flex", gap: 8 }}>
                      <select
                        value={editRole}
                        onChange={(e) => setEditRole(e.target.value)}
                        style={{ padding: 4 }}
                      >
                        <option value="">Select Role</option>
                        {allowedRoles.map((r) => (
                          <option key={r} value={r}>
                            {r}
                          </option>
                        ))}
                      </select>
                      <button
                        onClick={() => handleUpdateRole(s.id)}
                        style={{ padding: "4px 8px" }}
                      >
                        Save
                      </button>
                      <button
                        onClick={() => setEditingId(null)}
                        style={{ padding: "4px 8px" }}
                      >
                        Cancel
                      </button>
                    </div>
                  ) : (
                    <span
                      style={{
                        padding: "2px 8px",
                        borderRadius: 4,
                        backgroundColor: getRoleBadgeColor(s.role),
                        color: "#fff",
                        fontSize: 13,
                      }}
                    >
                      {s.role}
                    </span>
                  )}
                </td>
                <td style={tdStyle}>
                  {/* Action buttons for each row */}
                  <div style={{ display: "flex", gap: 8 }}>
                    <button
                      onClick={() => {
                        setEditingId(s.id);
                        setEditRole(s.role);
                      }}
                      style={{ padding: "4px 12px" }}
                    >
                      Edit Role
                    </button>
                    <button
                      onClick={() => handleDelete(s.id, s.email)}
                      style={{
                        padding: "4px 12px",
                        backgroundColor: "#e74c3c",
                        color: "#fff",
                        border: "none",
                        borderRadius: 4,
                        cursor: "pointer",
                      }}
                    >
                      Delete
                    </button>
                  </div>
                </td>
              </tr>
            ))
          )}
        </tbody>
      </table>
    </div>
  );
}

What's happening in this JSX?

The page header has a toggle button that shows or hides the add-staff form. The form renders email, password, and role fields. The role dropdown is populated from allowedRoles, so Admins and Doctors each see only the roles they can assign.

Each table row has a conditional Role column. When editingId matches the row's ID, it swaps the role badge for a dropdown with Save and Cancel buttons. Otherwise it displays the role as a colored badge.

The Actions column has an Edit Role button (which activates inline editing) and a red Delete button (which triggers handleDelete with a confirmation dialog).

  • Below the component's closing brace, add the helper function and style constants that the table uses:
// Returns a background color for each role badge
function getRoleBadgeColor(role: string): string {
  switch (role) {
    case "Admin":
      return "#e74c3c";
    case "Doctor":
      return "#3498db";
    case "Receptionist":
      return "#2ecc71";
    case "BillingStaff":
      return "#f39c12";
    default:
      return "#95a5a6";
  }
}

const thStyle = {
  borderBottom: "2px solid #ccc",
  textAlign: "left" as const,
  padding: 8,
};

const tdStyle = {
  borderBottom: "1px solid #eee",
  padding: 8,
};

What does getRoleBadgeColor do?

getRoleBadgeColor maps each role to a distinct color: red for Admin, blue for Doctor, green for Receptionist, and orange for BillingStaff. This makes it easy to visually distinguish roles in the table at a glance.

  • Save StaffPage.tsx.
  • Switch to your browser and click Staff Management in the Navbar.

You should see the Staff Management page with an empty table that says "No staff members found." and an Add Staff button in the top right.

Don't see the Staff Management page?

  • Check for red errors in the browser console. A missing import or a typo in the file name will prevent the page from rendering.
  • Make sure your API is still running. The page calls GET /api/users on load, and a network error will set the page to an error state.
  • Confirm that the file is named StaffPage.tsx (capital S, capital P) and is inside src/pages/.

Still stuck? Ask for help.

Test the add-staff form

The add-staff form only appears when you click the toggle button. The role dropdown is filtered by your current role, so as an Admin you can create Doctors, Receptionists, and BillingStaff.

  • Click the Add Staff button in the top right of the page.
  • Enter a test email in the Email field (for example, doctor@hospital.com).
  • Enter a password with at least 6 characters in the Password field.
  • Open the role dropdown and select Doctor.
  • Click Create.

The form should collapse and the new staff member should appear in the table with a blue "Doctor" badge.

Why does the role dropdown only show certain roles?

The allowedRoles variable filters the dropdown options based on ROLE_OPTIONS[user.role]. This means the frontend mirrors the same hierarchy your backend enforces. Even if someone edited the frontend to show all roles, the API would still reject an unauthorized assignment with a 403.

Seeing an error when creating a staff member?

  • If you see a password error, make sure the password is at least 6 characters and includes a mix of uppercase, lowercase, digits, and special characters. ASP.NET Core Identity has strict defaults.
  • If you see a duplicate email error, the email is already registered. Try a different email address.

Help me debug my staff creation form.

Test inline role editing and deletion

The table supports inline editing and deletion. Clicking Edit Role replaces the role badge with a dropdown for that row. Clicking Delete shows a browser confirmation dialog before removing the staff member.

  • Find the Doctor you just created in the table.
  • Click Edit Role in their Actions column.
  • Select Receptionist from the dropdown.
  • Click Save.

The badge should change from blue (Doctor) to green (Receptionist) immediately.

  • Now click Delete on the same staff member.
  • Click OK in the browser confirmation dialog.

The staff member should disappear from the table. If there are no other managed staff, the table shows "No staff members found." again.

Role edit or delete not working?

  • Open the browser console and check for network errors. A 403 means the API rejected the request based on your role hierarchy.
  • If the Edit Role dropdown appears but Save does nothing, check that you selected a role from the dropdown before clicking Save. An empty selection will cause a validation error.

Help me debug inline editing or deletion on the Staff Management page.

✔️ Awesome, I've got everything!

Great. Double check you have saved StaffPage.tsx.

ⓧ I'd like to double check the full code

Here is the complete StaffPage.tsx file. Compare your code against it:

import { useEffect, useState } from "react";
import axiosClient from "../api/axiosClient";
import { useAuth } from "../context/AuthContext";

interface StaffMember {
  id: string;
  email: string;
  role: string;
}

const ROLE_OPTIONS: Record<string, string[]> = {
  Admin: ["Doctor", "Receptionist", "BillingStaff"],
  Doctor: ["Receptionist", "BillingStaff"],
};

export default function StaffPage() {
  const { user } = useAuth();
  const [staff, setStaff] = useState<StaffMember[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  const [showForm, setShowForm] = useState(false);
  const [newEmail, setNewEmail] = useState("");
  const [newPassword, setNewPassword] = useState("");
  const [newRole, setNewRole] = useState("");
  const [saving, setSaving] = useState(false);

  const [editingId, setEditingId] = useState<string | null>(null);
  const [editRole, setEditRole] = useState("");

  const allowedRoles = user ? ROLE_OPTIONS[user.role] || [] : [];

  const fetchStaff = async () => {
    try {
      const res = await axiosClient.get("/api/users");
      setStaff(res.data);
      setError("");
    } catch {
      setError("Failed to load staff.");
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchStaff();
  }, []);

  const handleCreate = async () => {
    if (!newEmail || !newPassword || !newRole) {
      setError("All fields are required.");
      return;
    }
    setSaving(true);
    try {
      await axiosClient.post("/api/users", {
        email: newEmail,
        password: newPassword,
        role: newRole,
      });
      setNewEmail("");
      setNewPassword("");
      setNewRole("");
      setShowForm(false);
      setError("");
      await fetchStaff();
    } catch (err: any) {
      setError(err.response?.data?.message || "Failed to create staff.");
    } finally {
      setSaving(false);
    }
  };

  const handleUpdateRole = async (id: string) => {
    try {
      await axiosClient.put(`/api/users/${id}/role`, { role: editRole });
      setEditingId(null);
      setEditRole("");
      setError("");
      await fetchStaff();
    } catch (err: any) {
      setError(err.response?.data?.message || "Failed to update role.");
    }
  };

  const handleDelete = async (id: string, email: string) => {
    if (!window.confirm(`Delete staff member ${email}? This cannot be undone.`))
      return;
    try {
      await axiosClient.delete(`/api/users/${id}`);
      setError("");
      await fetchStaff();
    } catch (err: any) {
      setError(err.response?.data?.message || "Failed to delete staff.");
    }
  };

  if (loading) return <div style={{ padding: 20 }}>Loading staff...</div>;

  return (
    <div style={{ padding: 20 }}>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 20,
        }}
      >
        <h1>Staff Management</h1>
        <button
          onClick={() => setShowForm(!showForm)}
          style={{ padding: "8px 16px" }}
        >
          {showForm ? "Cancel" : "Add Staff"}
        </button>
      </div>

      {error && <p style={{ color: "red", marginBottom: 12 }}>{error}</p>}

      {showForm && (
        <div
          style={{
            border: "1px solid #ccc",
            padding: 16,
            marginBottom: 24,
            borderRadius: 4,
          }}
        >
          <h3 style={{ marginTop: 0 }}>Add New Staff</h3>
          <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
            <input
              placeholder="Email"
              value={newEmail}
              onChange={(e) => setNewEmail(e.target.value)}
              style={{ padding: 8, flex: 1, minWidth: 200 }}
            />
            <input
              type="password"
              placeholder="Password"
              value={newPassword}
              onChange={(e) => setNewPassword(e.target.value)}
              style={{ padding: 8, flex: 1, minWidth: 200 }}
            />
            <select
              value={newRole}
              onChange={(e) => setNewRole(e.target.value)}
              style={{ padding: 8, minWidth: 150 }}
            >
              <option value="">Select Role</option>
              {allowedRoles.map((r) => (
                <option key={r} value={r}>
                  {r}
                </option>
              ))}
            </select>
            <button
              onClick={handleCreate}
              disabled={saving}
              style={{ padding: "8px 16px" }}
            >
              {saving ? "Creating..." : "Create"}
            </button>
          </div>
        </div>
      )}

      <table style={{ width: "100%", borderCollapse: "collapse" }}>
        <thead>
          <tr>
            <th style={thStyle}>Email</th>
            <th style={thStyle}>Role</th>
            <th style={thStyle}>Actions</th>
          </tr>
        </thead>
        <tbody>
          {staff.length === 0 ? (
            <tr>
              <td
                colSpan={3}
                style={{ ...tdStyle, textAlign: "center", color: "#888" }}
              >
                No staff members found.
              </td>
            </tr>
          ) : (
            staff.map((s) => (
              <tr key={s.id}>
                <td style={tdStyle}>{s.email}</td>
                <td style={tdStyle}>
                  {editingId === s.id ? (
                    <div style={{ display: "flex", gap: 8 }}>
                      <select
                        value={editRole}
                        onChange={(e) => setEditRole(e.target.value)}
                        style={{ padding: 4 }}
                      >
                        <option value="">Select Role</option>
                        {allowedRoles.map((r) => (
                          <option key={r} value={r}>
                            {r}
                          </option>
                        ))}
                      </select>
                      <button
                        onClick={() => handleUpdateRole(s.id)}
                        style={{ padding: "4px 8px" }}
                      >
                        Save
                      </button>
                      <button
                        onClick={() => setEditingId(null)}
                        style={{ padding: "4px 8px" }}
                      >
                        Cancel
                      </button>
                    </div>
                  ) : (
                    <span
                      style={{
                        padding: "2px 8px",
                        borderRadius: 4,
                        backgroundColor: getRoleBadgeColor(s.role),
                        color: "#fff",
                        fontSize: 13,
                      }}
                    >
                      {s.role}
                    </span>
                  )}
                </td>
                <td style={tdStyle}>
                  <div style={{ display: "flex", gap: 8 }}>
                    <button
                      onClick={() => {
                        setEditingId(s.id);
                        setEditRole(s.role);
                      }}
                      style={{ padding: "4px 12px" }}
                    >
                      Edit Role
                    </button>
                    <button
                      onClick={() => handleDelete(s.id, s.email)}
                      style={{
                        padding: "4px 12px",
                        backgroundColor: "#e74c3c",
                        color: "#fff",
                        border: "none",
                        borderRadius: 4,
                        cursor: "pointer",
                      }}
                    >
                      Delete
                    </button>
                  </div>
                </td>
              </tr>
            ))
          )}
        </tbody>
      </table>
    </div>
  );
}

function getRoleBadgeColor(role: string): string {
  switch (role) {
    case "Admin":
      return "#e74c3c";
    case "Doctor":
      return "#3498db";
    case "Receptionist":
      return "#2ecc71";
    case "BillingStaff":
      return "#f39c12";
    default:
      return "#95a5a6";
  }
}

const thStyle = {
  borderBottom: "2px solid #ccc",
  textAlign: "left" as const,
  padding: 8,
};

const tdStyle = {
  borderBottom: "1px solid #eee",
  padding: 8,
};

Your Staff Management page is fully functional. Next up, you will take on the Secret Mission and add a password reset feature so Admins and Doctors can reset passwords for staff they manage.

Secret mission

Add Password Reset for Managed Staff

Admins and Doctors need a way to reset passwords for the staff they manage. Can you add a secure password reset endpoint to the backend and wire up an inline reset UI on the Staff Management page?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Running API and frontend development servers.
  • PostgreSQL database with seeded roles and test staff users.
  • Korutla Hospital project folder with all code files.

Keep everything running

No action needed. Choose this if you are still actively building on the Korutla Hospital project or plan to continue with the next project in the series.

  • Your API and frontend servers will keep running in their terminal windows.
  • Your PostgreSQL database retains the seeded roles and any staff users you created during testing.
  • All project files remain in your Korutla Hospital folder on the Desktop.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact so you can pick up where you left off.

  • Switch to the terminal running your API and press Ctrl+C to stop the dotnet run process.
  • Switch to the terminal running your frontend dev server and press Ctrl+C to stop it.
  • Your project files and PostgreSQL database will remain on your machine for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Switch to the terminal running your API and press Ctrl+C to stop the dotnet run process.
  • Switch to the terminal running your frontend dev server and press Ctrl+C to stop it.
  • Drop the PostgreSQL database by running this command in a new terminal:
dropdb HospitalDb

What does this command do?

The dropdb command permanently deletes the PostgreSQL database named HospitalDb, including all patient records, billing data, seeded roles, and staff users. If your database uses a different name, replace HospitalDb with the name from your connection string.

  • Delete the project folder by running the following commands:

macOS

cd ~/Desktop
rm -rf KorutlaHospital

Windows

cd ~\Desktop
rmdir /s /q KorutlaHospital

Check your folder name

If your project folder has a different name, replace KorutlaHospital with the actual folder name on your Desktop.

Nice Work!

Nice Work!

Nice work! You just built a full role-based access control system for the Korutla Hospital application, from seeding Identity roles on the backend to enforcing permissions across both API endpoints and frontend routes.

You've learned how to:

  • Seed RBAC roles into ASP.NET Core Identity and embed role claims in JWT tokens so the backend can enforce [Authorize(Roles = "...")] on every request.
  • Build a UsersController with CRUD endpoints that enforce a role hierarchy, where Admins manage all staff and Doctors manage only Receptionists and BillingStaff.
  • Add a persistent Navbar with role-conditional links, a RoleRoute guard component, and a full Staff Management page with add, edit role, and delete functionality.
  • Secret Mission: Added a password reset feature so Admins and Doctors can reset passwords for staff they manage using Identity's token-based reset flow.

Ready to quiz yourself?