Module: Members

7 of 12 Pages

Design and handle the profile form

If you allow members to register and sign in to your site, they will expect the ability to update their information.

An earlier part of this series covered how to use configurable widgets for the registration and sign-in forms. Let’s explore an alternative approach now, utilizing a pre-defined page template on a page.

In some ways, our approach will be similar to using a dedicated MVC route that displays a view. However, using a page template on a page allows editors to see the page in the content tree. That way, editors can easily link the page with Xperience’s built-in form components and control the page’s URL slug.

Examine the requirements

Let’s take a moment to consider what should be possible to update on a standard profile page.

Since members can trigger the password reset process without signing in, we don’t need to include passwords in the profile form.

Email and username should be a bit more closely guarded, with processes similar to the email confirmation and password reset processes, where a special token is sent to verify the identity of the member, so we won’t include them as editable fields, though we can display them.

We can also display the date the member’s account was created, but this should not be editable for factual reasons.

We can, however, allow members to edit their name and name order, and their favorite coffee.

The form should display a success message when it’s able to update a member’s information, and validation errors when the member submits improperly formatted data.

While it is possible to add page template properties that allow editors to configure the form in a similar manner to the registration widget, this example will not go over the process.

Build the Update profile form

Let’s make a view component for managing the update profile form. This way, in case we ever decide to include it somewhere aside from this template, it’s easier to reuse.

Under the ~/Features/Membership/Profile folder in the TrainingGuides.Web project, create a ViewComponents folder to house the files.

Overall, the view component will be somewhat similar to the registration form widget, using an AJAX form with a partial view to manage validation and results.

Define the view model

The GuidesMemberProfileViewModel from earlier in the series contains the custom fields that we used to expand the built-in Member type, so we can inherit from this class for our update form, adding the extra fields that we need.

Let’s take another peek at that class as a refresher.

C#
GuidesMemberProfileViewModel.cs

using System.ComponentModel.DataAnnotations;

namespace TrainingGuides.Web.Features.Membership.Profile;

public class GuidesMemberProfileViewModel
{
    [DataType(DataType.Text)]
    [MaxLength(50)]
    [Display(Name = "Given name")]
    public string GivenName { get; set; } = string.Empty;

    [DataType(DataType.Text)]
    [MaxLength(50)]
    [Display(Name = "Family name")]
    public string FamilyName { get; set; } = string.Empty;

    [Display(Name = "Family name goes first")]
    public bool FamilyNameFirst { get; set; } = false;

    [DataType(DataType.Text)]
    [MaxLength(100)]
    [Display(Name = "Favorite coffee")]
    public string FavoriteCoffee { get; set; } = string.Empty;
}

Back in the ~/Features/Membership/Profile/ViewComponents folder, create a new class called UpdateProfileViewModel that inherits from GuidesMemberProfileViewModel.

  • For form fields that should be displayed but not editable (e.g., EmailAddress), add properties decorated with the Display attribute.
  • For values that aren’t part of the form but need to persist through a post return (e.g., SubmitButtonText), use the HiddenInput attribute like you did in the registration form,.
  • For properties only used in the initial setup (e.g., Title), or which only apply after a result is returned (e.g., SuccessMessage), no attributes are necessary.
C#
UpdateProfileViewModel.cs

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

namespace TrainingGuides.Web.Features.Membership.Profile;

public class UpdateProfileViewModel : GuidesMemberProfileViewModel
{
    private string fullName = string.Empty;

    [Display(Name = "Full name")]
    public string FullName
    {
        get => !string.IsNullOrWhiteSpace(fullName)
            ? fullName
            : FamilyNameFirst
                ? $"{FamilyName} {GivenName}"
                : $"{GivenName} {FamilyName}";
        set => fullName = value ?? string.Empty;
    }

    [Display(Name = "Email address")]
    public string EmailAddress { get; set; } = string.Empty;

    [Display(Name = "User name")]
    public string UserName { get; set; } = string.Empty;

    [Display(Name = "Member since")]
    public DateTime Created { get; set; }

    [HiddenInput]
    public string SubmitButtonText { get; set; } = string.Empty;

    public string Title { get; set; } = string.Empty;

    public string ActionUrl { get; set; } = string.Empty;

    public string SuccessMessage { get; set; } = string.Empty;
}

Add a service

Since our view model needs data from multiple sources, such as information about the current member and about the base URL of the site, let’s make a separate service to populate it, rather than a static method within a view model.

Add a Services folder in the Profile directory and define a service that assembles a view model for a given member.

C#
IUpdateProfileService.cs

namespace TrainingGuides.Web.Features.Membership.Profile;

public interface IUpdateProfileService
{
    /// <summary>
    /// Get the view model for the update profile view component.
    /// </summary>
    /// <param name="guidesMember">The member to base the view model on.</param>
    /// <returns>An <see cref="UpdateProfileViewModel"/> based on the values of the <see cref="GuidesMember"/>'s properties.</returns>
    UpdateProfileViewModel GetViewModel(GuidesMember guidesMember);
}
C#
UpdateProfileService.cs

using Kentico.Content.Web.Mvc.Routing;
using Microsoft.Extensions.Localization;
using TrainingGuides.Web.Features.Shared.Helpers;
using TrainingGuides.Web.Features.Shared.Services;

namespace TrainingGuides.Web.Features.Membership.Profile;

public class UpdateProfileService : IUpdateProfileService
{
    private readonly IHttpRequestService httpRequestService;
    private readonly IPreferredLanguageRetriever preferredLanguageRetriever;
    private readonly IStringLocalizer<SharedResources> stringLocalizer;

    public UpdateProfileService(IHttpRequestService httpRequestService,
        IPreferredLanguageRetriever preferredLanguageRetriever,
        IStringLocalizer<SharedResources> stringLocalizer)
    {
        this.httpRequestService = httpRequestService;
        this.preferredLanguageRetriever = preferredLanguageRetriever;
        this.stringLocalizer = stringLocalizer;
    }

    /// <inheritdoc/>
    public UpdateProfileViewModel GetViewModel(GuidesMember guidesMember) =>
        new()
        {
            GivenName = guidesMember?.GivenName ?? string.Empty,
            FamilyName = guidesMember?.FamilyName ?? string.Empty,
            FamilyNameFirst = guidesMember?.FamilyNameFirst ?? false,
            FavoriteCoffee = guidesMember?.FavoriteCoffee ?? string.Empty,
            UserName = guidesMember?.UserName ?? string.Empty,
            EmailAddress = guidesMember?.Email ?? string.Empty,
            FullName = guidesMember?.FullName ?? string.Empty,
            Created = guidesMember?.Created ?? DateTime.MinValue,
            ActionUrl = httpRequestService.GetAbsoluteUrlForPath(ApplicationConstants.UPDATE_PROFILE_ACTION_PATH, true),
            SubmitButtonText = stringLocalizer["Submit"],
            Title = stringLocalizer["Update profile"]
        };
}

Remember to register the service with the DI container.

C#
~/ServiceCollectionExtensions.cs

...
public static void AddTrainingGuidesServices(this IServiceCollection services)
{
    ...
    services.AddSingleton<IUpdateProfileService, UpdateProfileService>();
    ...
}
...

Create the view component class

Now we can define the main file of our view component for updating the profile of the current member. Make sure to pre-populate the model with data about the current member, so the member does not need to manually re-fill all of their information every time.

To improve the editor experience, add functionality to display a dummy member in Preview or Page Builder view of the profile page. This way, editors who have not signed in as members won’t see a broken, empty profile when they visit the page in the Xperience admin UI.

You can prevent unauthenticated members from seeing the profile form by setting its page to require authentication.

We’ll explore this process in more detail later in the membership series.

C#
UpdateProfileViewComponent.cs

using Kentico.Content.Web.Mvc;
using Kentico.PageBuilder.Web.Mvc;
using Kentico.Web.Mvc;
using Microsoft.AspNetCore.Mvc;
using TrainingGuides.Web.Features.Membership.Services;

namespace TrainingGuides.Web.Features.Membership.Profile;

public class UpdateProfileViewComponent : ViewComponent
{
    private readonly IMembershipService membershipService;
    private readonly IHttpContextAccessor httpContextAccessor;
    private readonly IUpdateProfileService updateProfileService;

    public UpdateProfileViewComponent(IMembershipService membershipService,
        IHttpContextAccessor httpContextAccessor,
        IUpdateProfileService updateProfileService)
    {
        this.membershipService = membershipService;
        this.httpContextAccessor = httpContextAccessor;
        this.updateProfileService = updateProfileService;

    }

    public async Task<IViewComponentResult> InvokeAsync()
    {
        var httpContext = httpContextAccessor.HttpContext;
        GuidesMember currentMember;

        bool useDummyMember = httpContext.Kentico().PageBuilder().GetMode() != PageBuilderMode.Off || httpContext.Kentico().Preview().Enabled;

        currentMember = useDummyMember
            ? membershipService.DummyMember
            : await membershipService.GetCurrentMember() ?? membershipService.DummyMember;

        var model = updateProfileService.GetViewModel(currentMember);

        return View("~/Features/Membership/Profile/ViewComponents/UpdateProfile.cshtml", model);
    }
}
C#
~/Features/Membership/Services/IMembershipService.cs

...
/// <summary>
/// Generates a dummy member for display in page builder and preview modes.
/// </summary>
/// <returns> A dummy member</returns>
GuidesMember DummyMember { get; }
...
C#
~/Features/Membership/Services/MembershipService.cs

...
/// <inheritdoc/>
public GuidesMember DummyMember => new()
{
    UserName = "JohnDoe",
    Email = "JohnDoe@localhost.local",
    GivenName = "John",
    FamilyName = "Doe",
    FamilyNameFirst = false,
    FavoriteCoffee = "Latte",
    Enabled = true,
    Created = DateTime.Now,
    Id = 0
};
...

Create views for the view component

Let’s start by creating the view returned by the component’s InvokeAsync method.

Much like with the registration widget, create an AJAX form that posts to a special action, and use a separate partial view for the form fields within the update target element.

cshtml
UpdateProfile.cshtml

@using TrainingGuides.Web.Features.Membership.Profile;
@using TrainingGuides.Web.Features.Shared.Helpers;

@model UpdateProfileViewModel

@{
    // Using a new guid ensures no conflict if, for some reason, multiple instances of the view component are on the same page.
    string formDivId = $"updateProfileForm{Guid.NewGuid()}";
}
<h3>@Model.Title</h3>

@using (Html.AjaxBeginForm(new AjaxOptions
{
    HttpMethod = "POST",
    InsertionMode = InsertionMode.Replace,
    UpdateTargetId = formDivId
}, new { action = Model.ActionUrl }))
{
    <div id="@formDivId" class="px-2">
        <partial name="~/Features/Membership/Profile/ViewComponents/UpdateProfileForm.cshtml" model="Model" />
    </div>
}

C#
~/Features/Shared/Helpers/ApplicationConstants.cs

...
public const string UPDATE_PROFILE_ACTION_PATH = "/MemberManagement/UpdateProfile";
...

Now let’s add the partial view that the above code expects.

Displaying non-editable fields

For fields that members are not supposed to edit, do not use the input tag helper with the readonly attribute. Render them directly instead.

This way, values such as the full name, which can change based on a form submission, will update when a response comes back from the controller.

Directly rendered values also clearly indicate to members that the fields are not editable.

If there is a success message, meaning the controller is returning the current instance of the view after a successful update, render it to the page.

cshtml
UpdateProfileForm.cshtml

@using TrainingGuides.Web.Features.Membership.Profile;

@model UpdateProfileViewModel

<div class="form-horizontal">
    <div asp-validation-summary="ModelOnly" class="text-danger field-validation-error"></div>

    <input asp-for="SubmitButtonText">

    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="UserName" class="control-label form-label mt-3"></label>
        </div>
        <div class="ms-2">
            <strong>@Model.UserName</strong>
        </div>
    </div>
    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="FullName" class="control-label form-label mt-3"></label>
        </div>
        <div class="ms-2">
            <strong>@Model.FullName</strong>
        </div>
    </div>
    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="EmailAddress" class="control-label form-label mt-3"></label>
        </div>
        <div class="ms-2">
            <strong>@Model.EmailAddress</strong>
        </div>
    </div>
    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="Created" class="control-label form-label mt-3"></label>
        </div>
        <div class="ms-2">
            <strong>@Model.Created</strong>
        </div>
    </div>
    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="GivenName" class="control-label form-label mt-3"></label>
        </div>
        <div class="editing-form-value-cell">
            <input asp-for="GivenName" class="form-control" test-id="givenName">
            <span asp-validation-for="GivenName" class="text-danger field-validation-error"></span>
        </div>
    </div>
    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="FamilyName" class="control-label form-label mt-3"></label>
        </div>
        <div class="editing-form-value-cell">
            <input asp-for="FamilyName" class="form-control" test-id="familyName">
            <span asp-validation-for="FamilyName" class="text-danger field-validation-error"></span>
        </div>
    </div>
    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="FamilyNameFirst" class="control-label form-label mt-3"></label>
        </div>
        <div class="editing-form-value-cell">
            <input asp-for="FamilyNameFirst" class="form-check" test-id="familyNameFirst">
            <span asp-validation-for="FamilyNameFirst" class="text-danger field-validation-error"></span>
        </div>
    </div>
    <div class="form-group">
        <div class="editing-form-label-cell">
            <label asp-for="FavoriteCoffee" class="control-label form-label mt-3"></label>
        </div>
        <div class="editing-form-value-cell">
            <input asp-for="FavoriteCoffee" class="form-control" test-id="favoriteCoffee">
            <span asp-validation-for="FavoriteCoffee" class="text-danger field-validation-error"></span>
        </div>
    </div>
    
    <div class="text-center">
        <button id="register" type="submit" class="btn tg-btn-secondary text-uppercase my-4">@Model.SubmitButtonText</button>
    </div>
    @if (!string.IsNullOrEmpty(Model.SuccessMessage))
    {
        <div class="text-center">
            <strong>@Model.SuccessMessage</strong>
        </div>
    }
</div>

Handle the form with a controller action

In the Controllers directory of the Membership folder, add a new controller called MemberManagementController.

You may already have this file if you’ve taken the time to replicate the reset password functionality from the finished branch of the Training guides repository.

Create a POST action using the same path constant as the UpdateProfile view, adding any new functionality that you need to the membership service.

C#
MemberManagementController.cs


using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using TrainingGuides.Web.Features.Membership.Profile;
using TrainingGuides.Web.Features.Membership.Services;
using TrainingGuides.Web.Features.Shared.Helpers;


namespace TrainingGuides.Web.Features.Membership.Controllers;

public class MemberManagementController : Controller
{
    private readonly IMembershipService membershipService;
    private readonly IStringLocalizer<SharedResources> stringLocalizer;

    private const string UPDATE_PROFILE_FORM_VIEW_PATH = "~/Features/Membership/Profile/ViewComponents/UpdateProfileForm.cshtml";

    public MemberManagementController(IMembershipService membershipService,
        IStringLocalizer<SharedResources> stringLocalizer)
    {
        this.membershipService = membershipService;
        this.stringLocalizer = stringLocalizer;
    }

    /// <summary>
    /// Updates a user profile.
    /// </summary>
    /// <param name="model">View model with profile fields to update.</param>
    /// <returns></returns>
    [HttpPost($"{{{ApplicationConstants.LANGUAGE_KEY}}}{ApplicationConstants.UPDATE_PROFILE_ACTION_PATH}")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> UpdateProfile(UpdateProfileViewModel model)
    {
        if (!ModelState.IsValid)
        {
            return PartialView(UPDATE_PROFILE_FORM_VIEW_PATH, model);
        }

        //Get the current member instead of pulling from the model, so that members cannot attempt to change each others information.
        var guidesMember = await membershipService.GetCurrentMember();

        if (guidesMember is not null)
        {
            var result = await membershipService.UpdateMemberProfile(guidesMember, model);

            if (result.Succeeded)
            {
                var newModel = GetNewUpdateProfileViewModel(model,
                    guidesMember,
                    stringLocalizer["Profile updated successfully."]);

                return PartialView(UPDATE_PROFILE_FORM_VIEW_PATH, newModel);
            }
            else
            {
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }
            }
        }
        return PartialView(UPDATE_PROFILE_FORM_VIEW_PATH, model);
    }

    private UpdateProfileViewModel GetNewUpdateProfileViewModel(UpdateProfileViewModel model, GuidesMember guidesMember, string successMessage) =>
        new()
        {
            Title = model.Title,
            EmailAddress = guidesMember.Email ?? string.Empty,
            UserName = guidesMember.UserName ?? string.Empty,
            Created = guidesMember.Created,
            FullName = guidesMember.FullName,
            GivenName = guidesMember.GivenName,
            FamilyName = guidesMember.FamilyName,
            FamilyNameFirst = guidesMember.FamilyNameFirst,
            FavoriteCoffee = guidesMember.FavoriteCoffee,
            SubmitButtonText = model.SubmitButtonText,
            SuccessMessage = successMessage,
        };
}

Make sure to retrieve the currently signed-in member, rather than retrieving the member based on a submitted field. This way, nobody can update a different member’s details by forging a request to submit a username or email that is not their own.

C#
~/Features/Membership/Services/IMembershipService.cs

...
/// <summary>
/// Updates the profile of a member.
/// </summary>
/// <param name="member">Member to update.</param>
/// <param name="updateProfileViewModel">ViewModel with updated fields.</param>
/// <returns></returns>
Task<IdentityResult> UpdateMemberProfile(GuidesMember member, UpdateProfileViewModel updateProfileViewModel);
...
C#
~/Features/Membership/Services/MembershipService.cs

...
/// <inheritdoc />
public async Task<IdentityResult> UpdateMemberProfile(GuidesMember guidesMember, UpdateProfileViewModel updateProfileViewModel)
{
    guidesMember.GivenName = updateProfileViewModel.GivenName;
    guidesMember.FamilyName = updateProfileViewModel.FamilyName;
    guidesMember.FamilyNameFirst = updateProfileViewModel.FamilyNameFirst;
    guidesMember.FavoriteCoffee = updateProfileViewModel.FavoriteCoffee;

    SynchronizeContact(guidesMember);

    return await userManager.UpdateAsync(guidesMember);
}
...