Skip to content
This repository has been archived by the owner on Jul 10, 2024. It is now read-only.

Latest commit

 

History

History
696 lines (548 loc) · 24.2 KB

5. Add personal agenda.md

File metadata and controls

696 lines (548 loc) · 24.2 KB

Add attendee sign up

In this section we'll add features that track attendees who have registered on the site and allow them to create a personal agenda.

Add BackEnd attendee and FrontEnd user association

  1. Update the AuthHelpers file in the Infrastructure folder, adding members to the AuthConstants and AuthnHelpers 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));
        }
    }
  2. Update the ClaimsPrincipalFactory class in the Areas/Identity folder and add code to GenerateClaimsAsync that adds the IsAttendee 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;
        }
    }
  3. Add a Welcome.cshtml Razor page and Welcome.cshtml.cs page model in the Pages folder.

  4. 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>
    @section Scripts {
        <partial name="_ValidationScriptsPartial" />
    }
  5. Create a Models folder under your pages folder, create a class called Attendee.cs, then create a class which adds display specific information for an attendee

    using 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; }
        }
    }
  6. 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.Pages
    {
        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");
            }
        }
    }
  7. Logged in users can now be associated with an attendee by visiting this page.

Add a middleware to force logged in users to sign up on welcome page

  1. Add a folder called Middleware.

  2. 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;
    
    namespace FrontEnd
    {
        [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
        public class SkipWelcomeAttribute : Attribute
        {
    
        }
    }
  3. Add a new class called RequireLoginMiddleware.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 RequireLoginMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly LinkGenerator _linkGenerator;
    
        public RequireLoginMiddleware(RequestDelegate next, LinkGenerator linkGenerator)
        {
            _next = next;
            _linkGenerator = linkGenerator;
        }
    
        public Task Invoke(HttpContext context)
        {
            var endpoint = context.GetEndpoint();
    
            // 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.User.Identity.IsAuthenticated &&
                endpoint?.Metadata.GetMetadata<SkipWelcomeAttribute>() == null)
            {
                var isAttendee = context.User.IsAttendee();
    
                if (!isAttendee)
                {
                    var url = _linkGenerator.GetUriByPage(context, page: "/Welcome");
                    // No attendee registerd for this user
                    context.Response.Redirect(url);
    
                    return Task.CompletedTask;
                }
            }
    
            return _next(context);
        }
    }
  4. Add the RequireLoginMiddleware in Program.cs before app.MapRazorPages():

    app.UseMiddleware<RequireLoginMiddleware>();
    
    app.MapRazorPages();
  5. Update the Welcome.cshtml.cs class with the attribute to ensure it is skipped when the global filter runs:

    [SkipWelcome]
    public class WelcomeModel : PageModel
    {
        ...
  6. This should force all logged in users to register as an attendee.

Scaffold and update Logout page

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 the Logout 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 the Register page.

Using Visual Studio

  1. Right-click on the FrontEnd project and select Add / New Scaffolded Item / Identity.
  2. Check the Account\Logout page, select the IdentityDbContext as the Data context class, and press Add.

Using command line

  1. Run this command from the FrontEnd project folder.

    dotnet aspnet-codegenerator identity --dbContext FrontEnd.Data.IdentityDbContext --files Account.Logout

Next

  1. Add the [SkipWelcome] attribute to the LogoutModel class:

    [SkipWelcome]
    public class LogoutModel : PageModel
    {
        ...
  2. You're now able to log out if you create a new user but don't register as an attendee.

Add personal agenda

Update the ApiClient

  1. Add the following methods to IApiClient:

    Task<List<SessionResponse>> GetSessionsByAttendeeAsync(string name);
    Task AddSessionToAttendeeAsync(string name, int sessionId);
    Task RemoveSessionFromAttendeeAsync(string name, int sessionId);
  2. 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)
    {
        var response = await _httpClient.GetAsync($"/api/attendees/{name}/sessions");
    
        response.EnsureSuccessStatusCode();
    
        return await response.Content.ReadFromJsonAsync<List<SessionResponse>>();
    }

Add the BackEnd API to get sessions by an attendee

  1. Add an action called GetSessions to the AttendeesEndpoints in the BackEnd project:

    routes.MapGet("/api/Attendee/{username}/Sessions",
    async (string username, ApplicationDbContext db) =>
    {
        var sessions = await db.Sessions.AsNoTracking()
            .Include(s => s.Track)
            .Include(s => s.SessionSpeakers)
                .ThenInclude(ss => ss.Speaker)
            .Where(s => s.SessionAttendees.Any(sa => sa.Attendee.UserName == username))
            .Select(m => m.MapSessionResponse())
            .ToListAsync();
    
        return sessions is IQueryable<Data.Session>
                ? Results.Ok(sessions)
                : Results.NotFound();
    
    })
    .WithTags("Attendee")
    .WithName("GetAllSessionsForAttendee")
    .Produces<List<Data.Session>>(StatusCodes.Status200OK)
    .Produces(StatusCodes.Status404NotFound);

Add Add/Remove to personal agenda buttons to Session details page

  1. Add a property IsInPersonalAgenda to Session.cshtml.cs:

    public bool IsInPersonalAgenda { get; set; }
  2. Compute the value of that property in OnGetAsync:

    Session = await _apiClient.GetSessionAsync(id);
    
    if (Session == null)
    {
        return RedirectToPage("/Index");
    }
    
    if (User.Identity.IsAuthenticated)
    {
        var sessions = await _apiClient.GetSessionsByAttendeeAsync(User.Identity.Name);
    
        IsInPersonalAgenda = sessions.Any(s => s.Id == id);
    }
  3. 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>
  4. 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 the site.css reference:

    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/css/ionicons.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
  5. Add OnPostAsync handlers to Session.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();
    }
  6. Attendees should now be able to add/remove sessions to/from their personal agenda.

Add MyAgenda page

  1. Add MyAgenda.cshtml and MyAgenda.cshtml.cs files to the Pages folder.

  2. 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 the MyAgenda page.

  3. Add a virtual GetSessionsAsync method to Index.cshtml.cs:

    protected virtual Task<List<SessionResponse>> GetSessionsAsync()
    {
        return _apiClient.GetSessionsAsync();
    }
  4. Change the _apiClient field in Index.cshtml.cs to be protected instead of private:

    protected readonly IApiClient _apiClient;
  5. 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();
  6. 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(ILogger<IndexModel> logger, IApiClient apiClient)
                : base(logger, apiClient)
            {
    
            }
    
            protected override Task<List<SessionResponse>> GetSessionsAsync()
            {
                return _apiClient.GetSessionsByAttendeeAsync(User.Identity.Name);
            }
        }
    }
  7. Refactor the Index.cshtml into a _AgendaPartial.cshtml under the Pages\Shared folder. It should have the following content:

    @model IndexModel
    
    <ul class="nav nav-pills mb-3">
    @foreach (var day in Model.DayOffsets)
    {
        <li role="presentation" class="nav-item">
            <a class="nav-link @(Model.CurrentDayOffset == day.Offset ? "active" : null)" asp-route-day="@day.Offset">@day.DayofWeek?.ToString()</a>
        </li>
    }
    </ul>
    
    <div class="agenda">
        @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 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-xs">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>

    Note: This won't compile yet because we haven't added the UserSessions property to the IndexModel class. We'll be doing that soon.

  8. Update the Index.cshtml to use the new _AgendaPartial. Replace the entire div with the "agenda" css class and replace it with the following:

    <h1 class="mb-4">My Conference @System.DateTime.Now.Year</h1>
    
    <partial name="_AgendaPartial" model="Model" />
  9. Next, use the _AgendaPartial on the MyAgenda.cshtml page.

    @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">&times;</span></button>
            @Model.Message
        </div>
    }
    
    <h1 class="mb-4">My Agenda - My Conference @System.DateTime.Now.Year</h1>
    
    <partial name="_AgendaPartial" model="Model" />

Add the My Agenda link to the Layout

  1. Go to the layout file _Layout.cshtml.

  2. 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>

Update IndexModel to include User Sessions

  1. Add a UserSessions property to the IndexModel:

    public List<int> UserSessions { get; set; } = new List<int>();
  2. Update the OnGet method to fetch the UserSessions:

    public async Task OnGetAsync(int day = 0)
    {
        CurrentDayOffset = day;
    
        if (User.Identity.IsAuthenticated)
        {
            var userSessions = await _apiClient.GetSessionsByAttendeeAsync(User.Identity.Name);
            UserSessions = userSessions.Select(u => u.Id).ToList();
        }
    
        var sessions = await GetSessionsAsync();
        //...
  3. Add the following two methods to the IndexModel to handle adding and removing sessions from your agenda from the Index 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();
    }
  4. Run the application and test logging in and managing your agenda from the Index page, individual session details, and from the My Agenda page.

Next: Session #6 - Deployment | Previous: Session #4 - Authentication