From ee9f4405cc56e231f2e7692c29c792449b9636b5 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 9 Apr 2024 07:57:44 -0700 Subject: [PATCH 1/4] Add LoginForm Shapes Fix #15700 --- .../ReCaptchaLoginFormDisplayDriver.cs | 35 ++++++ .../OrchardCore.ReCaptcha.csproj | 1 + .../ReCaptchaLoginFilter.cs | 6 - .../OrchardCore.ReCaptcha/Startup.cs | 2 + .../Views/LoginFormReCaptcha.Edit.cshtml | 3 + .../Controllers/AccountController.cs | 110 ++++++++++-------- .../ForgotPasswordLoginFormDisplayDriver.cs | 29 +++++ .../Drivers/LoginFormDisplayDriver.cs | 36 ++++++ .../RegisterUserLoginFormDisplayDriver.cs | 29 +++++ .../OrchardCore.Users/Startup.cs | 4 +- .../Views/Account/Login.cshtml | 67 ++--------- .../Views/LoginForm.Edit.cshtml | 26 +++++ .../Views/LoginFormCredentials.Edit.cshtml | 42 +++++++ .../Views/LoginFormForgotPassword.Edit.cshtml | 3 + .../Views/LoginFormRegisterUser.Edit.cshtml | 3 + .../Models/LoginForm.cs | 12 ++ src/docs/releases/1.9.0.md | 29 ++++- 17 files changed, 322 insertions(+), 115 deletions(-) create mode 100644 src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/LoginFormReCaptcha.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/LoginForm.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.Edit.cshtml create mode 100644 src/OrchardCore/OrchardCore.Users.Core/Models/LoginForm.cs diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs new file mode 100644 index 00000000000..8b2432b7941 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Drivers/ReCaptchaLoginFormDisplayDriver.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.ReCaptcha.Configuration; +using OrchardCore.ReCaptcha.Services; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.ReCaptcha.Drivers; + +public class ReCaptchaLoginFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + private readonly ReCaptchaService _reCaptchaService; + + public ReCaptchaLoginFormDisplayDriver( + ISiteService siteService, + ReCaptchaService reCaptchaService) + { + _siteService = siteService; + _reCaptchaService = reCaptchaService; + } + + public override async Task EditAsync(LoginForm model, BuildEditorContext context) + { + var _reCaptchaSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!_reCaptchaSettings.IsValid() || !_reCaptchaService.IsThisARobot()) + { + return null; + } + + return View("LoginFormReCaptcha_Edit", model).Location("Content:after"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/OrchardCore.ReCaptcha.csproj b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/OrchardCore.ReCaptcha.csproj index 93e935cc715..2df1a9cfd03 100644 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/OrchardCore.ReCaptcha.csproj +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/OrchardCore.ReCaptcha.csproj @@ -23,6 +23,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/ReCaptchaLoginFilter.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/ReCaptchaLoginFilter.cs index e2908768b92..411d0e19e1f 100644 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/ReCaptchaLoginFilter.cs +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/ReCaptchaLoginFilter.cs @@ -49,12 +49,6 @@ public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultE var layout = await _layoutAccessor.GetLayoutAsync(); - if (_reCaptchaService.IsThisARobot()) - { - var afterLoginZone = layout.Zones["AfterLogin"]; - await afterLoginZone.AddAsync(await _shapeFactory.CreateAsync("ReCaptcha")); - } - var afterForgotPasswordZone = layout.Zones["AfterForgotPassword"]; await afterForgotPasswordZone.AddAsync(await _shapeFactory.CreateAsync("ReCaptcha")); diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs index 0e55ae780bd..b1d37986d92 100644 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs @@ -11,6 +11,7 @@ using OrchardCore.Settings; using OrchardCore.Settings.Deployment; using OrchardCore.Users.Events; +using OrchardCore.Users.Models; namespace OrchardCore.ReCaptcha { @@ -45,6 +46,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped, ReCaptchaLoginFormDisplayDriver>(); services.Configure((options) => { options.Filters.Add(); diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/LoginFormReCaptcha.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/LoginFormReCaptcha.Edit.cshtml new file mode 100644 index 00000000000..4db803c8837 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Views/LoginFormReCaptcha.Edit.cshtml @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs index 68939d50549..4a24806a212 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs @@ -12,6 +12,8 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Notify; using OrchardCore.Modules; using OrchardCore.Mvc.Core.Utilities; @@ -37,10 +39,13 @@ public class AccountController : AccountBaseController private readonly ISiteService _siteService; private readonly IEnumerable _accountEvents; private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IDisplayManager _loginFormDisplayManager; + private readonly IUpdateModelAccessor _updateModelAccessor; private readonly INotifier _notifier; private readonly IClock _clock; private readonly IDistributedCache _distributedCache; private readonly IEnumerable _externalLoginHandlers; + protected readonly IHtmlLocalizer H; protected readonly IStringLocalizer S; @@ -57,6 +62,8 @@ public AccountController( IClock clock, IDistributedCache distributedCache, IDataProtectionProvider dataProtectionProvider, + IDisplayManager loginFormDisplayManager, + IUpdateModelAccessor updateModelAccessor, IEnumerable externalLoginHandlers) { _signInManager = signInManager; @@ -69,6 +76,8 @@ public AccountController( _clock = clock; _distributedCache = distributedCache; _dataProtectionProvider = dataProtectionProvider; + _loginFormDisplayManager = loginFormDisplayManager; + _updateModelAccessor = updateModelAccessor; _externalLoginHandlers = externalLoginHandlers; H = htmlLocalizer; @@ -105,10 +114,13 @@ public async Task Login(string returnUrl = null) } } + var formShape = await _loginFormDisplayManager.BuildEditorAsync(_updateModelAccessor.ModelUpdater, false); + CopyTempDataErrorsToModelState(); + ViewData["ReturnUrl"] = returnUrl; - return View(); + return View(formShape); } [HttpGet] @@ -149,77 +161,79 @@ public async Task DefaultExternalLogin(string protectedToken, str [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] - public async Task Login(LoginViewModel model, string returnUrl = null) + [ActionName(nameof(Login))] + public async Task LoginPOST(string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; - ArgumentNullException.ThrowIfNull(model); + var model = new LoginForm(); - if (TryValidateModel(model) && ModelState.IsValid) + var formShape = await _loginFormDisplayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, false, string.Empty, string.Empty); + + var disableLocalLogin = (await _siteService.GetSiteSettingsAsync()).As().DisableLocalLogin; + + if (disableLocalLogin) { - var disableLocalLogin = (await _siteService.GetSiteSettingsAsync()).As().DisableLocalLogin; - if (disableLocalLogin) - { - ModelState.AddModelError(string.Empty, S["Local login is disabled."]); - } - else + ModelState.AddModelError(string.Empty, S["Local login is disabled."]); + } + else + { + await _accountEvents.InvokeAsync((e, model, modelState) => e.LoggingInAsync(model.UserName, (key, message) => modelState.AddModelError(key, message)), model, ModelState, _logger); + + if (ModelState.IsValid) { - await _accountEvents.InvokeAsync((e, model, modelState) => e.LoggingInAsync(model.UserName, (key, message) => modelState.AddModelError(key, message)), model, ModelState, _logger); - if (ModelState.IsValid) + var user = await _userService.GetUserAsync(model.UserName); + if (user != null) { - var user = await _userService.GetUserAsync(model.UserName); - if (user != null) + var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, lockoutOnFailure: true); + if (result.Succeeded) { - var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, lockoutOnFailure: true); - if (result.Succeeded) + if (!await AddConfirmEmailErrorAsync(user) && !AddUserEnabledError(user)) { - if (!await AddConfirmEmailErrorAsync(user) && !AddUserEnabledError(user)) - { - result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true); + result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true); - if (result.Succeeded) - { - _logger.LogInformation(1, "User logged in."); - await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); + if (result.Succeeded) + { + _logger.LogInformation(1, "User logged in."); + await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); - return await LoggedInActionResultAsync(user, returnUrl); - } + return await LoggedInActionResultAsync(user, returnUrl); } } + } - if (result.RequiresTwoFactor) - { - return RedirectToAction(nameof(TwoFactorAuthenticationController.LoginWithTwoFactorAuthentication), - typeof(TwoFactorAuthenticationController).ControllerName(), - new - { - returnUrl, - model.RememberMe - }); - } - - if (result.IsLockedOut) - { - ModelState.AddModelError(string.Empty, S["The account is locked out"]); - await _accountEvents.InvokeAsync((e, user) => e.IsLockedOutAsync(user), user, _logger); + if (result.RequiresTwoFactor) + { + return RedirectToAction(nameof(TwoFactorAuthenticationController.LoginWithTwoFactorAuthentication), + typeof(TwoFactorAuthenticationController).ControllerName(), + new + { + returnUrl, + model.RememberMe + }); + } - return View(); - } + if (result.IsLockedOut) + { + ModelState.AddModelError(string.Empty, S["The account is locked out"]); + await _accountEvents.InvokeAsync((e, user) => e.IsLockedOutAsync(user), user, _logger); - // Login failed with a known user. - await _accountEvents.InvokeAsync((e, user) => e.LoggingInFailedAsync(user), user, _logger); + return View(); } - ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); + // Login failed with a known user. + await _accountEvents.InvokeAsync((e, user) => e.LoggingInFailedAsync(user), user, _logger); } - // Login failed unknown user. - await _accountEvents.InvokeAsync((e, model) => e.LoggingInFailedAsync(model.UserName), model, _logger); + ModelState.AddModelError(string.Empty, S["Invalid login attempt."]); } + + // Login failed unknown user. + await _accountEvents.InvokeAsync((e, model) => e.LoggingInFailedAsync(model.UserName), model, _logger); } // If we got this far, something failed, redisplay form. - return View(model); + return View(formShape); } [HttpPost] diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs new file mode 100644 index 00000000000..65c301d5993 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/ForgotPasswordLoginFormDisplayDriver.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Drivers; + +public class ForgotPasswordLoginFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public ForgotPasswordLoginFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(LoginForm model, BuildEditorContext context) + { + var settings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!settings.AllowResetPassword) + { + return null; + } + + return View("LoginFormForgotPassword_Edit", model).Location("Links:5"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs new file mode 100644 index 00000000000..b7997dc2588 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginFormDisplayDriver.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Entities; +using OrchardCore.Users.Models; +using OrchardCore.Users.ViewModels; + +namespace OrchardCore.Users.Drivers; + +public class LoginFormDisplayDriver : DisplayDriver +{ + public override IDisplayResult Edit(LoginForm model) + { + return Initialize("LoginFormCredentials_Edit", vm => + { + vm.UserName = model.UserName; + vm.RememberMe = model.RememberMe; + }).Location("Content"); + } + + public override async Task UpdateAsync(LoginForm model, IUpdateModel updater) + { + var viewModel = new LoginViewModel(); + + await updater.TryUpdateModelAsync(viewModel, Prefix); + + model.UserName = viewModel.UserName; + model.Password = viewModel.Password; + model.RememberMe = viewModel.RememberMe; + + model.Put(viewModel); + + return Edit(model); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs new file mode 100644 index 00000000000..65311217fd8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RegisterUserLoginFormDisplayDriver.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Drivers; + +public class RegisterUserLoginFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public RegisterUserLoginFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(LoginForm model, BuildEditorContext context) + { + var settings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (settings.UsersCanRegister != UserRegistrationType.AllowRegistration) + { + return null; + } + + return View("LoginFormRegisterUser_Edit", model).Location("Links:10"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs index 393c5bf417f..b878ff8587d 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs @@ -212,6 +212,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddRecipeExecutionStep(); + services.AddScoped, LoginFormDisplayDriver>(); } } @@ -379,6 +380,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped, RegistrationSettingsDisplayDriver>(); + services.AddScoped, RegisterUserLoginFormDisplayDriver>(); } } @@ -401,7 +403,6 @@ public class ResetPasswordStartup : StartupBase private const string ResetPasswordConfirmationPath = "ResetPasswordConfirmation"; private const string ResetPasswordControllerName = "ResetPassword"; - public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) { routes.MapAreaControllerRoute( @@ -439,6 +440,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped, ResetPasswordSettingsDisplayDriver>(); + services.AddScoped, ForgotPasswordLoginFormDisplayDriver>(); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml index 6f5f4b55666..2ba0283caed 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/Account/Login.cshtml @@ -1,17 +1,19 @@ @using Microsoft.AspNetCore.Identity +@using OrchardCore.DisplayManagement.ModelBinding @using OrchardCore.Entities @using OrchardCore.Settings @using OrchardCore.Users @using OrchardCore.Users.Models -@model OrchardCore.Users.ViewModels.LoginViewModel + @inject SignInManager SignInManager @inject ISiteService SiteService -@inject UserManager UserManager; +@inject UserManager UserManager +@inject IDisplayManager LoginFormDisplayManager +@inject IUpdateModelAccessor UpdateModelAccessor + @{ ViewLayout = "Layout__Login"; - var userCanRegister = (await SiteService.GetSiteSettingsAsync()).As().UsersCanRegister == UserRegistrationType.AllowRegistration; - var allowResetPassword = (await SiteService.GetSiteSettingsAsync()).As().AllowResetPassword; var loginProviders = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToList(); var disableLocalLogin = (await SiteService.GetSiteSettingsAsync()).As().DisableLocalLogin; } @@ -29,45 +31,10 @@ @if (!disableLocalLogin) {
-
-

@T["Log in"]

-
- -
- - - -
-
- -
- - -
- -
-
- - -
- @await RenderSectionAsync("AfterLogin", required: false) -
- - @if (userCanRegister) - { - - } -
+ @await DisplayAsync(Model)
} + @if (loginProviders.Count > 0) {
@@ -84,21 +51,3 @@
} - diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginForm.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginForm.Edit.cshtml new file mode 100644 index 00000000000..55c314870b4 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginForm.Edit.cshtml @@ -0,0 +1,26 @@ + +
+

@T["Log in"]

+
+ + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } + +
+ +
+ + @if (Model.Links != null) + { +
    + @await DisplayAsync(Model.Links) +
+ } + + @if (Model.Footer != null) + { + @await DisplayAsync(Model.Footer) + } +
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.Edit.cshtml new file mode 100644 index 00000000000..04b153341d0 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormCredentials.Edit.cshtml @@ -0,0 +1,42 @@ +@model LoginViewModel + +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.Edit.cshtml new file mode 100644 index 00000000000..098426e2cf0 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormForgotPassword.Edit.cshtml @@ -0,0 +1,3 @@ +
  • + @T["Forgot your password?"] +
  • \ No newline at end of file diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.Edit.cshtml new file mode 100644 index 00000000000..5f054d81eb9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginFormRegisterUser.Edit.cshtml @@ -0,0 +1,3 @@ +
  • + @T["Register as a new user"] +
  • diff --git a/src/OrchardCore/OrchardCore.Users.Core/Models/LoginForm.cs b/src/OrchardCore/OrchardCore.Users.Core/Models/LoginForm.cs new file mode 100644 index 00000000000..9f189608c0f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Users.Core/Models/LoginForm.cs @@ -0,0 +1,12 @@ +using OrchardCore.Entities; + +namespace OrchardCore.Users.Models; + +public class LoginForm : Entity +{ + public string UserName { get; set; } + + public string Password { get; set; } + + public bool RememberMe { get; set; } +} diff --git a/src/docs/releases/1.9.0.md b/src/docs/releases/1.9.0.md index 4673ad1b2ff..46ebef5a139 100644 --- a/src/docs/releases/1.9.0.md +++ b/src/docs/releases/1.9.0.md @@ -206,7 +206,7 @@ Added a new `Navbar()` function to Liquid to allow building the `Navbar` shape u {{ navbar }} ``` -### Notifications +### Notifications Module The`INotificationMessage` interface was updated to includes the addition of a `Subject` field, which facilitates the rendering of notification titles. Moreover, the existing `Summary` field has been transitioned to HTML format. This adjustment enables the rendering of HTML notifications in both the navigation bar and the notification center. Consequently, HTML notifications can now be created, affording functionalities such as clickable notifications. @@ -215,3 +215,30 @@ Furthermore, the introduction of the `NotificationOptions` provides configuratio - `TotalUnreadNotifications`: This property determines the maximum number of unread notifications displayed in the navigation bar, with a default setting of 10. - `DisableNotificationHtmlBodySanitizer`: By default, the `HtmlBody` of notifications generated from workflows undergoes a sanitization process. However, this property grants the option to bypass this sanitization process. +### Users Module + +The `Login.cshtml` has undergone a significant revamp. The previous `AfterLogin` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the 'Forgot Password?' link is injected using the following driver: + +```csharp +public class ForgotPasswordLoginFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public ForgotPasswordLoginFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(LoginForm model, BuildEditorContext context) + { + var settings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!settings.AllowResetPassword) + { + return null; + } + + return View("LoginFormForgotPassword_Edit", model).Location("Links:5"); + } +} +``` \ No newline at end of file From 86da5e2468322ff63f5a4f47bbb4e567f5fee284 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 9 Apr 2024 16:57:04 -0700 Subject: [PATCH 2/4] Fix test --- .../cypress-commands/dist/index.mjs | 206 +++++++++--------- 1 file changed, 103 insertions(+), 103 deletions(-) diff --git a/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.mjs b/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.mjs index a9a6f2f44ad..c250835617b 100644 --- a/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.mjs +++ b/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.mjs @@ -1,153 +1,153 @@ -Cypress.Commands.add("login", function({ prefix = ""}={}) { - const config = Cypress.config('orchard'); - cy.visit(`${prefix}/login`); - cy.get("#UserName").type(config.username); - cy.get("#Password").type(config.password); - cy.get("#UserName").closest('form').submit(); +Cypress.Commands.add("login", function ({ prefix = "" } = {}) { + const config = Cypress.config('orchard'); + cy.visit(`${prefix}/login`); + cy.get("#LoginForm_UserName").type(config.username); + cy.get("#LoginForm_Password").type(config.password); + cy.get("#LoginForm_UserName").closest('form').submit(); }); Cypress.Commands.add("visitTenantSetupPage", ({ name }) => { - cy.visit("/Admin/Tenants"); - cy.get(`#btn-setup-${name}`).click(); + cy.visit("/Admin/Tenants"); + cy.get(`#btn-setup-${name}`).click(); }); -Cypress.Commands.add("siteSetup", ({ name, setupRecipe }) => { - const config = Cypress.config('orchard'); - cy.get("#SiteName").type(name); - cy.get("body").then($body => { - const elem = $body.find("#RecipeName"); - if (elem) { - elem.val(setupRecipe); - } - const db = $body.find("#DatabaseProvider"); - if(db.length > 0 && db.val() == "") { - db.val("Sqlite"); - } - }); - cy.get("#UserName").type(config.username); - cy.get("#Email").type(config.email); - cy.get("#Password").type(config.password); - cy.get("#PasswordConfirmation").type(config.password); - cy.get("#SubmitButton").click(); +Cypress.Commands.add("siteSetup", ({ name, setupRecipe }) => { + const config = Cypress.config('orchard'); + cy.get("#SiteName").type(name); + cy.get("body").then($body => { + const elem = $body.find("#RecipeName"); + if (elem) { + elem.val(setupRecipe); + } + const db = $body.find("#DatabaseProvider"); + if (db.length > 0 && db.val() == "") { + db.val("Sqlite"); + } + }); + cy.get("#UserName").type(config.username); + cy.get("#Email").type(config.email); + cy.get("#Password").type(config.password); + cy.get("#PasswordConfirmation").type(config.password); + cy.get("#SubmitButton").click(); }); -Cypress.Commands.add('newTenant', function(tenantInfo) { - cy.login(); - cy.createTenant(tenantInfo); - cy.visitTenantSetupPage(tenantInfo); - cy.siteSetup(tenantInfo); +Cypress.Commands.add('newTenant', function (tenantInfo) { + cy.login(); + cy.createTenant(tenantInfo); + cy.visitTenantSetupPage(tenantInfo); + cy.siteSetup(tenantInfo); }); Cypress.Commands.add("createTenant", ({ name, prefix, setupRecipe, description }) => { - // We create tenants on the SaaS tenant - cy.visit("/Admin/Tenants"); - cy.btnCreateClick(); - cy.get("#Name").type(name, {force:true}); - cy.get("#Description").type(`Recipe: ${setupRecipe}. ${description || ''}`, {force:true}); - cy.get("#RequestUrlPrefix").type(prefix, {force:true}); - cy.get("#RecipeName").select(setupRecipe); - cy.get("body").then($body => { - const db = $body.find("#DatabaseProvider"); - // if a database provider is already specified by an environment variable.. leave it as is - // this assumes that if you set the provider, you also set the connectionString - if (db.length > 0 && db.val() == "") { - db.val('Sqlite'); - } else { - //set the tablePrefix to the name. - const prefix = $body.find("#TablePrefix"); - if(prefix.length > 0){ - prefix.val(name); + // We create tenants on the SaaS tenant + cy.visit("/Admin/Tenants"); + cy.btnCreateClick(); + cy.get("#Name").type(name, { force: true }); + cy.get("#Description").type(`Recipe: ${setupRecipe}. ${description || ''}`, { force: true }); + cy.get("#RequestUrlPrefix").type(prefix, { force: true }); + cy.get("#RecipeName").select(setupRecipe); + cy.get("body").then($body => { + const db = $body.find("#DatabaseProvider"); + // if a database provider is already specified by an environment variable.. leave it as is + // this assumes that if you set the provider, you also set the connectionString + if (db.length > 0 && db.val() == "") { + db.val('Sqlite'); + } else { + //set the tablePrefix to the name. + const prefix = $body.find("#TablePrefix"); + if (prefix.length > 0) { + prefix.val(name); + } } - } - }); - cy.btnCreateClick(); + }); + cy.btnCreateClick(); }); Cypress.Commands.add("runRecipe", ({ prefix }, recipeName) => { - cy.visit(`${prefix}/Admin/Recipes`); - cy.get(`#btn-run-${recipeName}`).click(); - cy.btnModalOkClick(); + cy.visit(`${prefix}/Admin/Recipes`); + cy.get(`#btn-run-${recipeName}`).click(); + cy.btnModalOkClick(); }); Cypress.Commands.add("uploadRecipeJson", ({ prefix }, fixturePath) => { cy.fixture(fixturePath).then((data) => { - cy.visit(`${prefix}/Admin/DeploymentPlan/Import/Json`); - cy.get('.CodeMirror').should('be.visible'); - cy.get("body").then($body => { - $body.find(".CodeMirror")[0].CodeMirror.setValue(JSON.stringify(data)); - }); - cy.get('.ta-content > form').submit(); - // make sure the message-success alert is displayed - cy.get('.message-success').should('contain', "Recipe imported"); + cy.visit(`${prefix}/Admin/DeploymentPlan/Import/Json`); + cy.get('.CodeMirror').should('be.visible'); + cy.get("body").then($body => { + $body.find(".CodeMirror")[0].CodeMirror.setValue(JSON.stringify(data)); + }); + cy.get('.ta-content > form').submit(); + // make sure the message-success alert is displayed + cy.get('.message-success').should('contain', "Recipe imported"); }); - }); +}); function byCy(id, exact) { - if (exact) { - return `[data-cy="${id}"]`; - } - return `[data-cy^="${id}"]`; + if (exact) { + return `[data-cy="${id}"]`; + } + return `[data-cy^="${id}"]`; } Cypress.Commands.add('getByCy', (selector, exact = false) => { - return cy.get(byCy(selector, exact)); + return cy.get(byCy(selector, exact)); }); Cypress.Commands.add( - 'findByCy', - {prevSubject: 'optional'}, - (subject, selector, exact = false) => { - return subject - ? cy.wrap(subject).find(byCy(selector, exact)) - : cy.find(byCy(selector, exact)); - }, + 'findByCy', + { prevSubject: 'optional' }, + (subject, selector, exact = false) => { + return subject + ? cy.wrap(subject).find(byCy(selector, exact)) + : cy.find(byCy(selector, exact)); + }, ); -Cypress.Commands.add("setPageSize", ({prefix = ""}, size) => { - cy.visit(`${prefix}/Admin/Settings/general`); - cy.get('#ISite_PageSize') - .clear() - .type(size); - cy.btnSaveClick(); - // wait until the success message is displayed - cy.get('.message-success'); +Cypress.Commands.add("setPageSize", ({ prefix = "" }, size) => { + cy.visit(`${prefix}/Admin/Settings/general`); + cy.get('#ISite_PageSize') + .clear() + .type(size); + cy.btnSaveClick(); + // wait until the success message is displayed + cy.get('.message-success'); }); Cypress.Commands.add("enableFeature", ({ prefix }, featureName) => { - cy.visit(`${prefix}/Admin/Features`); - cy.get(`#btn-enable-${featureName}`).click(); + cy.visit(`${prefix}/Admin/Features`); + cy.get(`#btn-enable-${featureName}`).click(); }); Cypress.Commands.add("diableFeature", ({ prefix }, featureName) => { - cy.visit(`${prefix}/Admin/Features`); - cy.get(`#btn-diable-${featureName}`).click(); + cy.visit(`${prefix}/Admin/Features`); + cy.get(`#btn-diable-${featureName}`).click(); }); Cypress.Commands.add("visitContentPage", ({ prefix }, contentItemId) => { - cy.visit(`${prefix}/Contents/ContentItems/${contentItemId}`); + cy.visit(`${prefix}/Contents/ContentItems/${contentItemId}`); }); -Cypress.Commands.add('btnCreateClick', function() { - cy.get('.btn.create').click(); +Cypress.Commands.add('btnCreateClick', function () { + cy.get('.btn.create').click(); }); -Cypress.Commands.add('btnSaveClick', function() { - cy.get('.btn.save').click(); +Cypress.Commands.add('btnSaveClick', function () { + cy.get('.btn.save').click(); }); -Cypress.Commands.add('btnSaveContinueClick', function() { - cy.get('.dropdown-item.save-continue').click(); +Cypress.Commands.add('btnSaveContinueClick', function () { + cy.get('.dropdown-item.save-continue').click(); }); -Cypress.Commands.add('btnCancelClick', function() { - cy.get('.btn.cancel').click(); +Cypress.Commands.add('btnCancelClick', function () { + cy.get('.btn.cancel').click(); }); -Cypress.Commands.add('btnPublishClick', function() { - cy.get('.btn.public').click(); +Cypress.Commands.add('btnPublishClick', function () { + cy.get('.btn.public').click(); }); -Cypress.Commands.add('btnPublishContinueClick', function() { - cy.get('.dropdown-item.publish-continue').click(); +Cypress.Commands.add('btnPublishContinueClick', function () { + cy.get('.dropdown-item.publish-continue').click(); }); -Cypress.Commands.add('btnModalOkClick', function() { - cy.get("#modalOkButton").click(); +Cypress.Commands.add('btnModalOkClick', function () { + cy.get("#modalOkButton").click(); }); From c6efdfeab544356add4e94382c060c3274c51aaa Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 9 Apr 2024 17:16:29 -0700 Subject: [PATCH 3/4] fix build --- .../cypress-commands/dist/index.js | 206 +++++++++--------- 1 file changed, 103 insertions(+), 103 deletions(-) diff --git a/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.js b/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.js index 24aaf9b5692..694161ce5b4 100644 --- a/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.js +++ b/test/OrchardCore.Tests.Functional/cypress-commands/dist/index.js @@ -1,155 +1,155 @@ 'use strict'; -Cypress.Commands.add("login", function({ prefix = ""}={}) { - const config = Cypress.config('orchard'); - cy.visit(`${prefix}/login`); - cy.get("#UserName").type(config.username); - cy.get("#Password").type(config.password); - cy.get("#UserName").closest('form').submit(); +Cypress.Commands.add("login", function ({ prefix = "" } = {}) { + const config = Cypress.config('orchard'); + cy.visit(`${prefix}/login`); + cy.get("#LoginForm_UserName").type(config.username); + cy.get("#LoginForm_Password").type(config.password); + cy.get("#LoginForm_UserName").closest('form').submit(); }); Cypress.Commands.add("visitTenantSetupPage", ({ name }) => { - cy.visit("/Admin/Tenants"); - cy.get(`#btn-setup-${name}`).click(); + cy.visit("/Admin/Tenants"); + cy.get(`#btn-setup-${name}`).click(); }); -Cypress.Commands.add("siteSetup", ({ name, setupRecipe }) => { - const config = Cypress.config('orchard'); - cy.get("#SiteName").type(name); - cy.get("body").then($body => { - const elem = $body.find("#RecipeName"); - if (elem) { - elem.val(setupRecipe); - } - const db = $body.find("#DatabaseProvider"); - if(db.length > 0 && db.val() == "") { - db.val("Sqlite"); - } - }); - cy.get("#UserName").type(config.username); - cy.get("#Email").type(config.email); - cy.get("#Password").type(config.password); - cy.get("#PasswordConfirmation").type(config.password); - cy.get("#SubmitButton").click(); +Cypress.Commands.add("siteSetup", ({ name, setupRecipe }) => { + const config = Cypress.config('orchard'); + cy.get("#SiteName").type(name); + cy.get("body").then($body => { + const elem = $body.find("#RecipeName"); + if (elem) { + elem.val(setupRecipe); + } + const db = $body.find("#DatabaseProvider"); + if (db.length > 0 && db.val() == "") { + db.val("Sqlite"); + } + }); + cy.get("#UserName").type(config.username); + cy.get("#Email").type(config.email); + cy.get("#Password").type(config.password); + cy.get("#PasswordConfirmation").type(config.password); + cy.get("#SubmitButton").click(); }); -Cypress.Commands.add('newTenant', function(tenantInfo) { - cy.login(); - cy.createTenant(tenantInfo); - cy.visitTenantSetupPage(tenantInfo); - cy.siteSetup(tenantInfo); +Cypress.Commands.add('newTenant', function (tenantInfo) { + cy.login(); + cy.createTenant(tenantInfo); + cy.visitTenantSetupPage(tenantInfo); + cy.siteSetup(tenantInfo); }); Cypress.Commands.add("createTenant", ({ name, prefix, setupRecipe, description }) => { - // We create tenants on the SaaS tenant - cy.visit("/Admin/Tenants"); - cy.btnCreateClick(); - cy.get("#Name").type(name, {force:true}); - cy.get("#Description").type(`Recipe: ${setupRecipe}. ${description || ''}`, {force:true}); - cy.get("#RequestUrlPrefix").type(prefix, {force:true}); - cy.get("#RecipeName").select(setupRecipe); - cy.get("body").then($body => { - const db = $body.find("#DatabaseProvider"); - // if a database provider is already specified by an environment variable.. leave it as is - // this assumes that if you set the provider, you also set the connectionString - if (db.length > 0 && db.val() == "") { - db.val('Sqlite'); - } else { - //set the tablePrefix to the name. - const prefix = $body.find("#TablePrefix"); - if(prefix.length > 0){ - prefix.val(name); + // We create tenants on the SaaS tenant + cy.visit("/Admin/Tenants"); + cy.btnCreateClick(); + cy.get("#Name").type(name, { force: true }); + cy.get("#Description").type(`Recipe: ${setupRecipe}. ${description || ''}`, { force: true }); + cy.get("#RequestUrlPrefix").type(prefix, { force: true }); + cy.get("#RecipeName").select(setupRecipe); + cy.get("body").then($body => { + const db = $body.find("#DatabaseProvider"); + // if a database provider is already specified by an environment variable.. leave it as is + // this assumes that if you set the provider, you also set the connectionString + if (db.length > 0 && db.val() == "") { + db.val('Sqlite'); + } else { + //set the tablePrefix to the name. + const prefix = $body.find("#TablePrefix"); + if (prefix.length > 0) { + prefix.val(name); + } } - } - }); - cy.btnCreateClick(); + }); + cy.btnCreateClick(); }); Cypress.Commands.add("runRecipe", ({ prefix }, recipeName) => { - cy.visit(`${prefix}/Admin/Recipes`); - cy.get(`#btn-run-${recipeName}`).click(); - cy.btnModalOkClick(); + cy.visit(`${prefix}/Admin/Recipes`); + cy.get(`#btn-run-${recipeName}`).click(); + cy.btnModalOkClick(); }); Cypress.Commands.add("uploadRecipeJson", ({ prefix }, fixturePath) => { cy.fixture(fixturePath).then((data) => { - cy.visit(`${prefix}/Admin/DeploymentPlan/Import/Json`); - cy.get('.CodeMirror').should('be.visible'); - cy.get("body").then($body => { - $body.find(".CodeMirror")[0].CodeMirror.setValue(JSON.stringify(data)); - }); - cy.get('.ta-content > form').submit(); - // make sure the message-success alert is displayed - cy.get('.message-success').should('contain', "Recipe imported"); + cy.visit(`${prefix}/Admin/DeploymentPlan/Import/Json`); + cy.get('.CodeMirror').should('be.visible'); + cy.get("body").then($body => { + $body.find(".CodeMirror")[0].CodeMirror.setValue(JSON.stringify(data)); + }); + cy.get('.ta-content > form').submit(); + // make sure the message-success alert is displayed + cy.get('.message-success').should('contain', "Recipe imported"); }); - }); +}); function byCy(id, exact) { - if (exact) { - return `[data-cy="${id}"]`; - } - return `[data-cy^="${id}"]`; + if (exact) { + return `[data-cy="${id}"]`; + } + return `[data-cy^="${id}"]`; } Cypress.Commands.add('getByCy', (selector, exact = false) => { - return cy.get(byCy(selector, exact)); + return cy.get(byCy(selector, exact)); }); Cypress.Commands.add( - 'findByCy', - {prevSubject: 'optional'}, - (subject, selector, exact = false) => { - return subject - ? cy.wrap(subject).find(byCy(selector, exact)) - : cy.find(byCy(selector, exact)); - }, + 'findByCy', + { prevSubject: 'optional' }, + (subject, selector, exact = false) => { + return subject + ? cy.wrap(subject).find(byCy(selector, exact)) + : cy.find(byCy(selector, exact)); + }, ); -Cypress.Commands.add("setPageSize", ({prefix = ""}, size) => { - cy.visit(`${prefix}/Admin/Settings/general`); - cy.get('#ISite_PageSize') - .clear() - .type(size); - cy.btnSaveClick(); - // wait until the success message is displayed - cy.get('.message-success'); +Cypress.Commands.add("setPageSize", ({ prefix = "" }, size) => { + cy.visit(`${prefix}/Admin/Settings/general`); + cy.get('#ISite_PageSize') + .clear() + .type(size); + cy.btnSaveClick(); + // wait until the success message is displayed + cy.get('.message-success'); }); Cypress.Commands.add("enableFeature", ({ prefix }, featureName) => { - cy.visit(`${prefix}/Admin/Features`); - cy.get(`#btn-enable-${featureName}`).click(); + cy.visit(`${prefix}/Admin/Features`); + cy.get(`#btn-enable-${featureName}`).click(); }); Cypress.Commands.add("diableFeature", ({ prefix }, featureName) => { - cy.visit(`${prefix}/Admin/Features`); - cy.get(`#btn-diable-${featureName}`).click(); + cy.visit(`${prefix}/Admin/Features`); + cy.get(`#btn-diable-${featureName}`).click(); }); Cypress.Commands.add("visitContentPage", ({ prefix }, contentItemId) => { - cy.visit(`${prefix}/Contents/ContentItems/${contentItemId}`); + cy.visit(`${prefix}/Contents/ContentItems/${contentItemId}`); }); -Cypress.Commands.add('btnCreateClick', function() { - cy.get('.btn.create').click(); +Cypress.Commands.add('btnCreateClick', function () { + cy.get('.btn.create').click(); }); -Cypress.Commands.add('btnSaveClick', function() { - cy.get('.btn.save').click(); +Cypress.Commands.add('btnSaveClick', function () { + cy.get('.btn.save').click(); }); -Cypress.Commands.add('btnSaveContinueClick', function() { - cy.get('.dropdown-item.save-continue').click(); +Cypress.Commands.add('btnSaveContinueClick', function () { + cy.get('.dropdown-item.save-continue').click(); }); -Cypress.Commands.add('btnCancelClick', function() { - cy.get('.btn.cancel').click(); +Cypress.Commands.add('btnCancelClick', function () { + cy.get('.btn.cancel').click(); }); -Cypress.Commands.add('btnPublishClick', function() { - cy.get('.btn.public').click(); +Cypress.Commands.add('btnPublishClick', function () { + cy.get('.btn.public').click(); }); -Cypress.Commands.add('btnPublishContinueClick', function() { - cy.get('.dropdown-item.publish-continue').click(); +Cypress.Commands.add('btnPublishContinueClick', function () { + cy.get('.dropdown-item.publish-continue').click(); }); -Cypress.Commands.add('btnModalOkClick', function() { - cy.get("#modalOkButton").click(); +Cypress.Commands.add('btnModalOkClick', function () { + cy.get("#modalOkButton").click(); }); From 358a45d86f736758d806546a400bf226fd2e3097 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 11 Apr 2024 11:13:21 -0700 Subject: [PATCH 4/4] make the Login.cshtml change part of the breaking changes. --- src/docs/releases/1.9.0.md | 56 +++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/docs/releases/1.9.0.md b/src/docs/releases/1.9.0.md index 0c042719eb8..2ee46f8e1aa 100644 --- a/src/docs/releases/1.9.0.md +++ b/src/docs/releases/1.9.0.md @@ -77,6 +77,34 @@ services.AddJsonDerivedTypeInfo(); } ``` +### Users Module + +The `Login.cshtml` has undergone a significant revamp. The previous `AfterLogin` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the 'Forgot Password?' link is injected using the following driver: + +```csharp +public class ForgotPasswordLoginFormDisplayDriver : DisplayDriver +{ + private readonly ISiteService _siteService; + + public ForgotPasswordLoginFormDisplayDriver(ISiteService siteService) + { + _siteService = siteService; + } + + public override async Task EditAsync(LoginForm model, BuildEditorContext context) + { + var settings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!settings.AllowResetPassword) + { + return null; + } + + return View("LoginFormForgotPassword_Edit", model).Location("Links:5"); + } +} +``` + ### Media Indexing Previously, `.pdf` files were automatically indexed in the search providers (Elasticsearch, Lucene or Azure AI Search). Now, if you want to continue to index `.PDF` file you'll need to enable the `OrchardCore.Media.Indexing.Pdf` feature. @@ -224,31 +252,3 @@ Furthermore, the introduction of the `NotificationOptions` provides configuratio - `TotalUnreadNotifications`: This property determines the maximum number of unread notifications displayed in the navigation bar, with a default setting of 10. - `DisableNotificationHtmlBodySanitizer`: By default, the `HtmlBody` of notifications generated from workflows undergoes a sanitization process. However, this property grants the option to bypass this sanitization process. - -### Users Module - -The `Login.cshtml` has undergone a significant revamp. The previous `AfterLogin` zone, which allowed filters for injecting shapes, has been replaced. Now, you can inject shapes using drivers by implementing `IDisplayDriver`. For example, the 'Forgot Password?' link is injected using the following driver: - -```csharp -public class ForgotPasswordLoginFormDisplayDriver : DisplayDriver -{ - private readonly ISiteService _siteService; - - public ForgotPasswordLoginFormDisplayDriver(ISiteService siteService) - { - _siteService = siteService; - } - - public override async Task EditAsync(LoginForm model, BuildEditorContext context) - { - var settings = (await _siteService.GetSiteSettingsAsync()).As(); - - if (!settings.AllowResetPassword) - { - return null; - } - - return View("LoginFormForgotPassword_Edit", model).Location("Links:5"); - } -} -``` \ No newline at end of file