In this section we'll add features that track attendees who have registered on the site and allow them to create a personal agenda.
-
Update the
AuthHelpers
file in theInfrastructure
folder, adding members to theAuthConstants
andAuthnHelpers
classes for working with attendee users:namespace FrontEnd.Infrastructure { public static class AuthConstants { public static readonly string IsAdmin = nameof(IsAdmin); public static readonly string IsAttendee = nameof(IsAttendee); public static readonly string TrueValue = "true"; } } namespace System.Security.Claims { public static class AuthnHelpers { public static bool IsAdmin(this ClaimsPrincipal principal) => principal.HasClaim(AuthConstants.IsAdmin, AuthConstants.TrueValue); public static void MakeAdmin(this ClaimsPrincipal principal) => principal.Identities.First().MakeAdmin(); public static void MakeAdmin(this ClaimsIdentity identity) => identity.AddClaim(new Claim(AuthConstants.IsAdmin, AuthConstants.TrueValue)); public static bool IsAttendee(this ClaimsPrincipal principal) => principal.HasClaim(AuthConstants.IsAttendee, AuthConstants.TrueValue); public static void MakeAttendee(this ClaimsPrincipal principal) => principal.Identities.First().MakeAttendee(); public static void MakeAttendee(this ClaimsIdentity identity) => identity.AddClaim(new Claim(AuthConstants.IsAttendee, AuthConstants.TrueValue)); } }
-
Update the
ClaimsPrincipalFactory
class in theAreas/Identity
folder and add code toGenerateClaimsAsync
that adds theIsAttendee
claim if the user is registered as an attendee:public class ClaimsPrincipalFactory : UserClaimsPrincipalFactory<User> { private readonly IApiClient _apiClient; public ClaimsPrincipalFactory(IApiClient apiClient, UserManager<User> userManager, IOptions<IdentityOptions> optionsAccessor) : base(userManager, optionsAccessor) { _apiClient = apiClient; } protected override async Task<ClaimsIdentity> GenerateClaimsAsync(User user) { var identity = await base.GenerateClaimsAsync(user); if (user.IsAdmin) { identity.MakeAdmin(); } var attendee = await _apiClient.GetAttendeeAsync(user.UserName); if (attendee != null) { identity.MakeAttendee(); } return identity; } }
-
Add a
Welcome.cshtml
Razor page andWelcome.cshtml.cs
page model in thePages
folder. -
Add a user sign up form to
Welcome.cshtml
:@page @using ConferenceDTO @model WelcomeModel <h2>Welcome @User.Identity.Name</h2> <p> Register as an attendee to get access to cool features. </p> <form method="post"> <div asp-validation-summary="All" class="text-danger"></div> <input asp-for="Attendee.UserName" value="@User.Identity.Name" type="hidden" /> <div class="form-group"> <label asp-for="Attendee.FirstName" class="control-label"></label> <div class="row"> <div class="col-md-6"> <input asp-for="Attendee.FirstName" class="form-control" /> </div> </div> <span asp-validation-for="Attendee.FirstName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Attendee.LastName" class="control-label"></label> <div class="row"> <div class="col-md-6"> <input asp-for="Attendee.LastName" class="form-control" /> </div> </div> <span asp-validation-for="Attendee.LastName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Attendee.EmailAddress" class="control-label"></label> <div class="row"> <div class="col-md-6"> <input asp-for="Attendee.EmailAddress" class="form-control" /> </div> </div> <span asp-validation-for="Attendee.EmailAddress" class="text-danger"></span> </div> <div class="form-group"> <div class=""> <button type="submit" class="btn btn-primary">Save</button> </div> </div> </form> <partial name="_ValidationScriptsPartial" />
-
Create a
Models
folder under your pages folder, create a class calledAttendee.cs
, then create a class which adds display specific information for an attendeeusing System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace FrontEnd.Pages.Models { public class Attendee : ConferenceDTO.Attendee { [DisplayName("First name")] public override string FirstName { get => base.FirstName; set => base.FirstName = value; } [DisplayName("Last name")] public override string LastName { get => base.LastName; set => base.LastName = value; } [DisplayName("Email address")] [DataType(DataType.EmailAddress)] public override string EmailAddress { get => base.EmailAddress; set => base.EmailAddress = value; } } }
-
In
Welcome.cshtml.cs
, add logic that associates the logged in user with an attendee:using System.Threading.Tasks; using FrontEnd.Services; using FrontEnd.Pages.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; namespace FrontEnd { public class WelcomeModel : PageModel { private readonly IApiClient _apiClient; public WelcomeModel(IApiClient apiClient) { _apiClient = apiClient; } [BindProperty] public Attendee Attendee { get; set; } public IActionResult OnGet() { // Redirect to home page if user is anonymous or already registered as attendee var isAttendee = User.IsAttendee(); if (!User.Identity.IsAuthenticated || isAttendee) { return RedirectToPage("/Index"); } return Page(); } public async Task<IActionResult> OnPostAsync() { var success = await _apiClient.AddAttendeeAsync(Attendee); if (!success) { ModelState.AddModelError("", "There was an issue creating the attendee for this user."); return Page(); } // Re-issue the auth cookie with the new IsAttendee claim User.MakeAttendee(); await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme, User); return RedirectToPage("/Index"); } } }
-
Logged in users can now be associated with an attendee by visiting this page.
-
Add a folder called
Filters
. -
Add a new attribute
SkipWelcomeAttribute.cs
to allow certain pages or action methods to be skipped from enforcing redirection to the Welcome page:using System; using Microsoft.AspNetCore.Mvc.Filters; namespace FrontEnd.Filters { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class SkipWelcomeAttribute : Attribute, IFilterMetadata { } }
-
Add a new class called
RequireLoginFilter.cs
that redirects to the Welcome page if the user is authenticated but not associated with an attendee (does not have the"IsAttendee"
claim):public class RequireLoginFilter : IAsyncResourceFilter { private readonly IUrlHelperFactory _urlHelperFactory; public RequireLoginFilter(IUrlHelperFactory urlHelperFactory) { _urlHelperFactory = urlHelperFactory; } public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next) { var urlHelper = _urlHelperFactory.GetUrlHelper(context); // If the user is authenticated but not a known attendee *and* we've not marked this page // to skip attendee welcome, then redirect to the Welcome page if (context.HttpContext.User.Identity.IsAuthenticated && !context.Filters.OfType<SkipWelcomeAttribute>().Any()) { var isAttendee = context.HttpContext.User.IsAttendee(); if (!isAttendee) { // No attendee registerd for this user context.HttpContext.Response.Redirect(urlHelper.Page("/Welcome")); return; } } await next(); } }
-
Register the
RequireLogin
filter globally with MVC using its options in theConfigureServices
method inStartup.cs
:services.AddMvc(options => { options.Filters.AddService<RequireLoginFilter>(); })
-
Register the filter in DI in the
ConfigureServices
method inStartup.cs
services.AddTransient<RequireLoginFilter>();
-
Update the
Welcome.cshtml.cs
class with the attribute to ensure it is skipped when the global filter runs:[SkipWelcome] public class WelcomeModel : PageModel { ...
-
This should force all logged in users to register as an attendee.
One problem with the above system is that if a new user creates an account but does not register as an attendee, they are unable to logout. The general solution to this is to add the
[SkipWelcome]
page to theLogout
view, but in order to do that we will need to scaffold it. Identity pages are served from the Identity UI NuGet package by default, but can be added to a project and overridden, as we have already seen in theRegister
page.
- Right-click on the
FrontEnd
project and select Add / New Scaffolded Item / Identity. - Check the
Account\Logout
page, select theIdentityDbContext
as the Data context class, and press Add.
- Run this command from the FrontEnd project folder.
dotnet aspnet-codegenerator identity --dbContext FrontEnd.Data.IdentityDbContext --files Account.Logout
- Add the
[SkipWelcome]
attribute to theLogoutModel
class:[SkipWelcome] [AllowAnonymous] public class LogoutModel : PageModel { ...
- You're now able to log out if you create a new user but don't register as an attendee.
-
Add the following methods to
IApiClient
:Task<List<SessionResponse>> GetSessionsByAttendeeAsync(string name); Task AddSessionToAttendeeAsync(string name, int sessionId); Task RemoveSessionFromAttendeeAsync(string name, int sessionId);
-
Add the implementations to
ApiClient
:public async Task AddSessionToAttendeeAsync(string name, int sessionId) { var response = await _httpClient.PostAsync($"/api/attendees/{name}/session/{sessionId}", null); response.EnsureSuccessStatusCode(); } public async Task RemoveSessionFromAttendeeAsync(string name, int sessionId) { var response = await _httpClient.DeleteAsync($"/api/attendees/{name}/session/{sessionId}"); response.EnsureSuccessStatusCode(); } public async Task<List<SessionResponse>> GetSessionsByAttendeeAsync(string name) { // TODO: Would be better to add backend API for this var sessionsTask = GetSessionsAsync(); var attendeeTask = GetAttendeeAsync(name); await Task.WhenAll(sessionsTask, attendeeTask); var sessions = await sessionsTask; var attendee = await attendeeTask; if (attendee == null) { return new List<SessionResponse>(); } var sessionIds = attendee.Sessions.Select(s => s.ID); sessions.RemoveAll(s => !sessionIds.Contains(s.ID)); return sessions; }
-
Add a property
IsInPersonalAgenda
toSession.cshtml.cs
:public bool IsInPersonalAgenda { get; set; }
-
Compute the value of that property in
OnGetAsync
:var sessions = await _apiClient.GetSessionsByAttendeeAsync(User.Identity.Name); IsInPersonalAgenda = sessions.Any(s => s.ID == id);
-
Add a form to the bottom of
Session.cshtml
razor page that adds the ability to add/remove the session to the attendee's personal agenda:<form method="post"> <input type="hidden" name="sessionId" value="@Model.Session.ID" /> <p> <a authz-policy="Admin" asp-page="/Admin/EditSession" asp-route-id="@Model.Session.ID" class="btn btn-default btn-sm">Edit</a> @if (Model.IsInPersonalAgenda) { <button authz="true" type="submit" asp-page-handler="Remove" class="btn btn-default btn-sm" title="Remove from my personal agenda"> <i class="icon ion-md-star" aria-hidden="true"></i> </button> } else { <button authz="true" type="submit" class="btn btn-default btn-sm bg-transparent" title="Add to my personal agenda"> <i class="icon ion-md-star-outline" aria-hidden="true"></i> </button> } </p> </form>
-
The above markup uses a star icon from the ionicons, one of the icon libraries recommended by Bootstrap. Let's add a CDN reference to this library in
Pages\Shared\_Layout.cshtml
, directly above thesite.css
reference:</environment> <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/css/ionicons.min.css" /> <link rel="stylesheet" href="~/css/site.css" />
-
Add
OnPostAsync
handlers toSession.cshtml.cs
that handles the adding/removing of the session to the personal agenda:public async Task<IActionResult> OnPostAsync(int sessionId) { await _apiClient.AddSessionToAttendeeAsync(User.Identity.Name, sessionId); return RedirectToPage(); } public async Task<IActionResult> OnPostRemoveAsync(int sessionId) { await _apiClient.RemoveSessionFromAttendeeAsync(User.Identity.Name, sessionId); return RedirectToPage(); }
-
Attendees should now be able to add/remove sessions to/from their personal agenda.
-
Add
MyAgenda.cshtml
andMyAgenda.cshtml.cs
files to thePages
folder. -
The Index page and MyAgenda page share the vast majority of their logic and rendering. We'll refactor the
Index.cshtml.cs
class so that it may be used as a base class for theMyAgenda
page. -
Add a
virtual
GetSessionsAsync
method toIndex.cshtml.cs
:protected virtual Task<List<SessionResponse>> GetSessionsAsync() { return _apiClient.GetSessionsAsync(); }
-
Change the
_apiClient
field inIndex.cshtml.cs
to beprotected
instead ofprivate
:protected readonly IApiClient _apiClient;
-
Change the logic in
OnGetAsync
to get session using the new virtual method we just added:Before
var sessions = _apiClient.GetSessionsAsync();
After
var sessions = await GetSessionsAsync();
-
Make the MyAgenda page model derive from the Index page model. Change
MyAgenda.cshtml.cs
to look like this:using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using ConferenceDTO; using FrontEnd.Services; using Microsoft.AspNetCore.Authorization; namespace FrontEnd.Pages { [Authorize] public class MyAgendaModel : IndexModel { public MyAgendaModel(IApiClient client) : base(client) { } protected override Task<List<SessionResponse>> GetSessionsAsync() { return _apiClient.GetSessionsByAttendeeAsync(User.Identity.Name); } } }
-
Add the HTML to render the list of sessions on the attendee's personal agenda to
MyAgenda.cshtml
:@page @model MyAgendaModel @{ ViewData["Title"] = "My Agenda"; } @if (Model.ShowMessage) { <div class="alert alert-success alert-dismissible" role="alert"> <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span> </button> @Model.Message </div> } <div class="agenda"> <h1>My Conference @System.DateTime.Now.Year</h1> @if (Model.UserSessions.Count == 0) { <p> You have not yet added any sessions to your agenda. Add sessions to your personal agenda by browsing the <a asp-page="/Index">conference agenda</a> or <a asp-page="/Search">searching</a> for sessions and speakers and clicking the star button. </p> } <ul class="nav nav-pills"> @foreach (var day in Model.DayOffsets) { <li role="presentation" class="@(Model.CurrentDayOffset == day.Offset ? "active" : null)"> <a asp-route-day="@day.Offset">@day.DayofWeek?.ToString()</a> </li> } </ul> @foreach (var timeSlot in Model.Sessions) { <h4>@timeSlot.Key?.ToString("HH:mm")</h4> <div class="row"> @foreach (var session in timeSlot) { <div class="col-md-3 mb-4"> <div class="card shadow session h-100"> <div class="card-header">@session.Track?.Name</div> <div class="card-body"> <h5 class="card-title"><a asp-page="Session" asp-route-id="@session.ID">@session.Title</a></h5> </div> <div class="card-footer"> <ul class="list-inline mb-0"> @foreach (var speaker in session.Speakers) { <li class="list-inline-item"> <a asp-page="Speaker" asp-route-id="@speaker.ID">@speaker.Name</a> </li> } </ul> <form authz="true" method="post"> <input type="hidden" name="sessionId" value="@session.ID" /> <p class="mb-0"> <a authz-policy="Admin" asp-page="/Admin/EditSession" asp-route-id="@session.ID" class="btn btn-default btn-sm">Edit</a> @if (Model.UserSessions.Contains(session.ID)) { <button type="submit" asp-page-handler="Remove" class="btn btn-default btn-sm bg-transparent" title="Remove from my personal agenda"> <i class="icon ion-md-star" aria-hidden="true"></i> </button> } else { <button type="submit" class="btn btn-default btn-sm bg-transparent" title="Add to my personal agenda"> <i class="icon ion-md-star-outline" aria-hidden="true"></i> </button> } </p> </form> </div> </div> </div> } </div> } </div>
-
Go to the layout file
_Layout.cshtml
. -
Add a link that shows up only when authenticated under the
/Speakers
link:<div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse"> <partial name="_LoginPartial" /> <ul class="navbar-nav flex-grow-1"> <li class="nav-item"> <a class="nav-link text-dark" asp-page="/Search">Search</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-page="/Index">Agenda</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-page="/Speakers">Speakers</a> </li> <li class="nav-item" authz="true"> <a class="nav-link text-dark" asp-page="/MyAgenda">My Agenda</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a> </li> </ul> </div>
-
Add a
[TempData]
backedMessage
property toIndexModel
, similar to the property we added to theEditSession
page:[TempData] public string Message { get; set; } public bool ShowMessage => !string.IsNullOrEmpty(Message);
-
You should be able to login as an attendee, add/remove sessions to your personal agenda and click on MyAgenda to have them show up.
-
Add a
UserSessions
property to theIndexModel
:public List<int> UserSessions { get; set; }
-
Update the
OnGet
method to fetch theUserSessions
:public async Task OnGet(int day = 0) { CurrentDayOffset = day; var userSessions = await _apiClient.GetSessionsByAttendeeAsync(User.Identity.Name); UserSessions = userSessions.Select(u => u.ID).ToList(); var sessions = await GetSessionsAsync(); //...
-
Add the following two methods to the
IndexModel
to handle adding and removing sessions from your agenda fron theIndex
page:public async Task<IActionResult> OnPostAsync(int sessionId) { await _apiClient.AddSessionToAttendeeAsync(User.Identity.Name, sessionId); return RedirectToPage(); } public async Task<IActionResult> OnPostRemoveAsync(int sessionId) { await _apiClient.RemoveSessionFromAttendeeAsync(User.Identity.Name, sessionId); return RedirectToPage(); }
-
Run the application and test logging in and managing your agenda from the
Index
page, individual session details, and from theMy Agenda
page.
Next: Session #6 - Deployment | Previous: Session #4 - Authentication