Включить интерактивный режим рендеринга на страницах учетных записей шаблонов Blazor

Вопрос:

Я работаю над проектом Blazor, где для всех страниц учетной записи установлен шаблон RenderMode.Server. Я хочу сделать некоторые из этих страниц учетной записи интерактивными, поэтому попробовал включить интерактивный режим рендеринга.

Однако при этом я столкнулся с несколькими проблемами:

  1. Бесконечный цикл: если я глобально включу интерактивный режим рендеринга, удалив фильтр на страницах учетной записи, я столкнусь с бесконечными циклами.
  2. Нулевые исключения: при включении интерактивного режима на определенной странице я сталкиваюсь с нулевыми исключениями.
  3. Ошибка «Ответ уже запущен». После обхода нулевых исключений с помощью проверок я получаю следующую ошибку:
    System.InvalidOperationException: OnStarting cannot be set because the response has already started.
       at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ThrowResponseAlreadyStartedException(String value)
    

Каков правильный подход к включению интерактивного режима рендеринга для страниц учетной записи в Blazor без возникновения этих проблем? Как я могу правильно обрабатывать аутентификацию и перенаправление, чтобы избежать этих ошибок?

Любые рекомендации или примеры будут с благодарностью!

Вот мой текущий пример страницы (ExternalLogin.razor):

@page "/Account/ExternalLogin"

@rendermode @(new InteractiveServerRenderMode(prerender: false))

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger

<PageTitle>Register</PageTitle>

<StatusMessage Message = "@message" />
<h1>Register</h1>
<h2>Associate your @ProviderDisplayName account.</h2>
<hr />

<div class = "alert alert-info">
    You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
    Please enter an email address for this site below and select the account type, then click the Register button to finish logging in.
</div>

<div class = "row">
    <div class = "col-md-4">
        <RadzenTemplateForm TItem = "InputModel" Data=@Input Submit=@OnValidSubmitAsync>
            <RadzenFieldset Text = "Register">
                <div class = "row mb-5">
                    <div class = "col-md-4" style = "align-self: center;">
                        <RadzenLabel Text = "Email" Component = "Email" />
                    </div>
                    <div class = "col">
                        <RadzenTextBox style = "display: block" Name = "Input.Email" @[email protected] class = "w-100" Placeholder = "Please enter your email" />
                        <RadzenRequiredValidator Component = "Input.Email" Text = "Email is required" Popup = "true" Style = "position: absolute" />
                        <RadzenEmailValidator Component = "Input.Email" Text = "Provide a valid email address" Popup = "true" Style = "position: absolute" />
                    </div>
                </div>
                <div class = "row mb-5">
                    <div class = "col-md-4" style = "align-self: center;">
                        <RadzenLabel Text = "Account Type" Component = "AccountType" />
                    </div>
                    <div class = "col">
                        <RadzenSelectBar @[email protected] TValue = "string" class = "mb-5">
                            <Items>
                                <RadzenSelectBarItem Icon = "person" Text = "Customer" Value = "RoleConstants.CustomerRoleName" IconColor = "Colors.Info" />
                                <RadzenSelectBarItem Icon = "cleaning_services" Text = "Cleaner" Value = "RoleConstants.StaffRoleName" IconColor = "@Colors.Success" />
                            </Items>
                        </RadzenSelectBar>
                     </div>
                </div>
            </RadzenFieldset>
            <RadzenButton ButtonType = "ButtonType.Submit" Size = "ButtonSize.Large" Icon = "save" Text = "Register" />
        </RadzenTemplateForm>
    </div>
</div>

@code {
    public const string LoginCallbackAction = "LoginCallback";

    private string? message;
    private ExternalLoginInfo externalLoginInfo = default!;

    [CascadingParameter]
    private HttpContext HttpContext { get; set; } = default!;

    [SupplyParameterFromForm]
    private InputModel Input { get; set; } = new();

    [SupplyParameterFromQuery]
    private string? RemoteError { get; set; }

    [SupplyParameterFromQuery]
    private string? ReturnUrl { get; set; }

    [SupplyParameterFromQuery]
    private string? Action { get; set; }

    private string? ProviderDisplayName => externalLoginInfo?.ProviderDisplayName;

    protected override async Task OnInitializedAsync()
    {
        if (RemoteError is not null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
        }

        var info = await SignInManager.GetExternalLoginInfoAsync();
        if (info is null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
        }

        externalLoginInfo = info;

        if (HttpMethods.IsGet(HttpContext.Request.Method))
        {
            if (Action == LoginCallbackAction)
            {
                await OnLoginCallbackAsync();
                return;
            }

            // We should only reach this page via the login callback, so redirect back to
            // the login page if we get here some other way.
            RedirectManager.RedirectTo("Account/Login");
        }
    }
    
    protected override void OnParametersSet()
    {
        if (HttpContext is null)
        {
            // If this code runs, we're currently rendering in interactive mode, so there is no HttpContext.
            // The identity pages need to set cookies, so they require an HttpContext. To achieve this we
            // must transition back from interactive mode to a server-rendered page.
            NavigationManager.Refresh(forceReload: true);
        }
    }

    private async Task OnLoginCallbackAsync()
    {
        // Sign in the user with this external login provider if the user already has a login.
        var result = await SignInManager.ExternalLoginSignInAsync(
            externalLoginInfo.LoginProvider,
            externalLoginInfo.ProviderKey,
            isPersistent: false,
            bypassTwoFactor: true);

        if (result.Succeeded)
        {
            Logger.LogInformation(
                "{Name} logged in with {LoginProvider} provider.",
                externalLoginInfo.Principal.Identity?.Name,
                externalLoginInfo.LoginProvider);
            RedirectManager.RedirectTo(ReturnUrl);
        }
        else if (result.IsLockedOut)
        {
            RedirectManager.RedirectTo("Account/Lockout");
        }

        // If the user does not have an account, then ask the user to create an account.
        if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
        }
    }

    private async Task OnValidSubmitAsync()
    {
        var emailStore = GetEmailStore();
        var user = CreateUser();

        await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await UserManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await UserManager.AddLoginAsync(user, externalLoginInfo);
            if (result.Succeeded)
            {
                Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);

                var userId = await UserManager.GetUserIdAsync(user);
                var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

                var callbackUrl = NavigationManager.GetUriWithQueryParameters(
                    NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
                    new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
                await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (UserManager.Options.SignIn.RequireConfirmedAccount)
                {
                    RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email });
                }

                await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
                RedirectManager.RedirectTo(ReturnUrl);
            }
        }

        message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
    }

    private ApplicationUser CreateUser()
    {
        try
        {
            return Activator.CreateInstance<ApplicationUser>();
        }
        catch
        {
            throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
                $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
        }
    }

    private IUserEmailStore<ApplicationUser> GetEmailStore()
    {
        if (!UserManager.SupportsUserEmail)
        {
            throw new NotSupportedException("The default UI requires a user store with email support.");
        }
        return (IUserEmailStore<ApplicationUser>)UserStore;
    }

    private sealed class InputModel
    {
        [Required]
        [EmailAddress]
        public string? Email { get; set; }

        [Required]
        public string AccountType { get; set; } = RoleConstants.CustomerRoleName;
    }
}

Проблемы:

  1. Когда я включаю интерактивный режим глобально, удаляя фильтр на страницах учетной записи, я получаю бесконечные циклы.
  2. Когда я включаю его

на этой конкретной странице я получаю нулевые исключения. Добавление нулевых проверок для обхода этих проверок приводит к ошибке «ответ уже запущен».

Что я пробовал:

  • Глобальный интерактивный режим: вызывает бесконечные циклы.
  • Интерактивный режим для конкретной страницы: вызывает нулевые исключения и ошибку «ответ уже запущен».

Глядя на другие вопросы и ответы, я вижу, что все они решают проблему как «исправленную», отключив ее на страницах учетной записи, что не является исправлением, в моем случае это признание ошибки как неисправимой.

Чего я ожидал: Страница станет интерактивной и будет работать так же, как другие.

Редактировать с помощью решения

Основываясь на принятом ответе, я нашел рабочее решение. мне пришлось разделить логику SSR и интерактивную логику, используя подход принятых ответов. В частности, основная часть, которую мне пришлось сохранить в родительском SSR, заключалась в том, что мне также пришлось заменить любой экземпляр RedirectManager стандартным диспетчером навигации в интерактивном компоненте.

Ниже мое рабочее решение

var info = await SignInManager.GetExternalLoginInfoAsync();

@page "/Account/ExternalLogin"

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger

<PageTitle>Register</PageTitle>

<StatusMessage Message = "@message" />
<h1>Register</h1>
<h2>Associate your @ProviderDisplayName account.</h2>
<hr />

<div class = "alert alert-info">
    You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
    Please enter an email address for this site below and select the account type, then click the Register button to finish logging in.
</div>

<ExternalLoginForm  Input = "@Input" 
                    ReturnUrl = "@ReturnUrl" 
                    ProviderKey = "@externalLoginInfo.ProviderKey" 
                    LoginProvider = "@externalLoginInfo.LoginProvider"
                    ProviderDisplayName = "@externalLoginInfo.ProviderDisplayName"
                   @rendermode=InteractiveServer />

@code {
    public const string LoginCallbackAction = "LoginCallback";

    private string? message;
    private ExternalLoginInfo externalLoginInfo = default!;

    [CascadingParameter]
    private HttpContext HttpContext { get; set; } = default!;

    [SupplyParameterFromForm]
    private ExternalLoginInputModel Input { get; set; } = new();

    [SupplyParameterFromQuery]
    private string? RemoteError { get; set; }

    [SupplyParameterFromQuery]
    private string? ReturnUrl { get; set; }

    [SupplyParameterFromQuery]
    private string? Action { get; set; }


    private string? ProviderDisplayName => externalLoginInfo?.ProviderDisplayName;

    protected override async Task OnInitializedAsync()
    {
        if (RemoteError is not null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
        }

        var info = await SignInManager.GetExternalLoginInfoAsync();
        if (info is null)
        {
            RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
        }

        externalLoginInfo = info;

        if (HttpMethods.IsGet(HttpContext.Request.Method))
        {
            if (Action == LoginCallbackAction)
            {
                await OnLoginCallbackAsync();
                return;
            }

            // We should only reach this page via the login callback, so redirect back to
            // the login page if we get here some other way.
            RedirectManager.RedirectTo("Account/Login");
        }
    }

    private async Task OnLoginCallbackAsync()
    {
        // Sign in the user with this external login provider if the user already has a login.
        var result = await SignInManager.ExternalLoginSignInAsync(
            externalLoginInfo.LoginProvider,
            externalLoginInfo.ProviderKey,
            isPersistent: false,
            bypassTwoFactor: true);

        if (result.Succeeded)
        {
            Logger.LogInformation(
                "{Name} logged in with {LoginProvider} provider.",
                externalLoginInfo.Principal.Identity?.Name,
                externalLoginInfo.LoginProvider);
            RedirectManager.RedirectTo(ReturnUrl);
        }
        else if (result.IsLockedOut)
        {
            RedirectManager.RedirectTo("Account/Lockout");
        }

        // If the user does not have an account, then ask the user to create an account.
        if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
        }
    }

}

ExternalLogin.razor

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger

<div class = "row">
    <div class = "col-md-4">

        <RadzenTemplateForm TItem = "ExternalLoginInputModel" Data=@Input Submit=@OnValidSubmitAsync>
            <RadzenFieldset Text = "Register">
                <div class = "row mb-5">
                    <div class = "col-md-4" style = "align-self: center;">
                        <RadzenLabel Text = "Email" Component = "Email" />
                    </div>
                    <div class = "col">
                        <RadzenTextBox style = "display: block" Name = "Input.Email" @[email protected] class = "w-100" Placeholder = "Please enter your email" />
                        <RadzenRequiredValidator Component = "Input.Email" Text = "Email is required" Popup = "true" Style = "position: absolute" />
                        <RadzenEmailValidator Component = "Input.Email" Text = "Provide a valid email address" Popup = "true" Style = "position: absolute" />
                    </div>
                </div>
                <div class = "row mb-5">
                    <div class = "col-md-4" style = "align-self: center;">
                        <RadzenLabel Text = "Account Type" Component = "AccountType" />
                    </div>
                    <div class = "col">
                        <RadzenSelectBar @[email protected] TValue = "string" class = "mb-5">
                            <Items>
                                <RadzenSelectBarItem Icon = "person" Text = "Customer" Value = "RoleConstants.CustomerRoleName" IconColor = "Colors.Info" />
                                <RadzenSelectBarItem Icon = "cleaning_services" Text = "Cleaner" Value = "RoleConstants.StaffRoleName" IconColor = "@Colors.Success" />
                            </Items>
                        </RadzenSelectBar>

                    </div>
                </div>
            </RadzenFieldset>

            <RadzenButton ButtonType = "ButtonType.Submit" Size = "ButtonSize.Large" Icon = "save" Text = "Register" />
        </RadzenTemplateForm>
    </div>
</div>

@code {

    [Parameter]
    public ExternalLoginInputModel Input { get; set; }

    [Parameter]
    public string ReturnUrl { get; set; }

    [Parameter]
    public string LoginProvider { get; set; }

    [Parameter]
    public string ProviderKey { get; set; }

    [Parameter]
    public string ProviderDisplayName { get; set; }


    private async Task OnValidSubmitAsync()
    {
        var emailStore = GetEmailStore();
        var user = CreateUser();

        await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await UserManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await UserManager.AddLoginAsync(user, new UserLoginInfo(LoginProvider, ProviderKey, ProviderDisplayName));

            user.Roles.Add(Input.AccountType);
            await UserManager.AddToRoleAsync(user, Input.AccountType);

            if (result.Succeeded)
            {
                Logger.LogInformation("User created an account using {Name} provider.", LoginProvider);

                var userId = await UserManager.GetUserIdAsync(user);
                var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

                var callbackUrl = NavigationManager.GetUriWithQueryParameters(
                    NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
                    new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
                await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (UserManager.Options.SignIn.RequireConfirmedAccount)
                {
                    NavigationManager.NavigateTo($"Account/RegisterConfirmation?email = {Input.Email}");
                }
                else
                {
                    await SignInManager.SignInAsync(user, isPersistent: false, LoginProvider);
                    NavigationManager.NavigateTo(ReturnUrl);
                }
            }
        }
    }

    private ApplicationUser CreateUser()
    {
        try
        {
            return Activator.CreateInstance<ApplicationUser>();
        }
        catch
        {
            throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
                $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
        }
    }

    private IUserEmailStore<ApplicationUser> GetEmailStore()
    {
        if (!UserManager.SupportsUserEmail)
        {
            throw new NotSupportedException("The default UI requires a user store with email support.");
        }
        return (IUserEmailStore<ApplicationUser>)UserStore;
    }

}

Все эти формы предназначены для «отправки» на конечные точки «Сервера», поэтому отключение предварительной обработки не приведет к созданию форм с токенами защиты от подделки.

Brian Parker 07.07.2024 23:31

[Вежливо] Почему? Аутентификация, судя по всему, вы ее настроили, — это действие на стороне сервера. Информация аутентификации записывается в конвейер HttpRequest на возвращаемую страницу. Если сделать страницу интерактивной, процесс аутентификации не станет интерактивным.

MrC aka Shaun Curtis 08.07.2024 14:11

@MrCakaShaunCurtis, мне нужна интерактивная обратная связь и возможность повторно использовать тот же стиль и поведение пользовательского интерфейса, что и остальную часть приложения. тот факт, что его аутентификационная информация может в этом контексте представлять проблему, но я могу обрабатывать аутентификационную информацию на любой другой интерактивной странице за пределами учетных записей, поэтому не вижу причины, по которой я не могу или не должен здесь. Я не пытаюсь сделать саму аутентификацию интерактивной (даже не понимаю, что это значит), я пытаюсь сделать поля, которые пользователь выдает, интерактивными.

ace90210 08.07.2024 22:31
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
3
65
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Вы не можете использовать интерактивный режим рендеринга на страницах аккаунта. Шаблон использует для них глобальный фильтр SSR, поскольку для работы им нужен httpcontext. (например, SignInManager, SigninAsync и т. д.)

HttpContext доступен только в SSR. В интерактивном режиме страница фактически использует веб-сокет, а не Http.

Вы можете попробовать обходной путь: поместить интерактивный элемент управления в пользовательский компонент, а затем

<CustomComponent @rendermode=InteractiveServer></CustomComponent>

Спасибо, это указало мне в правильном направлении. Принять эту идею и решить проблему пришлось немало потрудиться, но это была часть головоломки. Когда я прочитал это, это был какой-то момент «ага», поскольку я знал об этой функции, я просто не думал использовать ее для разделения SRR на логику страницы и интерактивную логику. Я буду обновлять свой вопрос своим рабочим решением.

ace90210 08.07.2024 22:34

Другие вопросы по теме