Hide Tenant Switch from an ABP Framework Login page

Introduction

In this step-by-step guide I will explain how you can hide the tenant switch on the login page of an ABP Framework application. After implementing all the steps below a user should be able to login with email address and password without losing multitenancy.

Source Code

The sample application has been developed with Blazor as UI framework and SQL Server as database provider.

The source code of the completed application is available on GitHub.

Requirements

The following tools are needed to run the solution.

  • .NET 5.0 SDK
  • VsCode, Visual Studio 2019 or another compatible IDE.

Development

Create a new ABP Framework Application

  • Install or update the ABP CLI:
dotnet tool install -g Volo.Abp.Cli || dotnet tool update -g Volo.Abp.Cli
  • Use the following ABP CLI command to create a new Blazor ABP application:
abp new AbpHideTenantSwitch -u blazor -o AbpHideTenantSwitch

Open & Run the Application

  • Open the solution in Visual Studio (or your favorite IDE).
  • Run the AbpHideTenantSwitch.DbMigrator application to apply the migrations and seed the initial data.
  • Run the AbpHideTenantSwitch.HttpApi.Host application to start the server side.
  • Run the AbpHideTenantSwitch.Blazor application to start the Blazor UI project.

Open HttpApi.Host.csproj and comment out the line below

<!-- <PackageReference Include="Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic" Version="4.4.3" /> -->

Paste Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic module into src folder of ABP project

  • Open a command prompt and clone the ABP repository into your computer.
   git clone https://github.com/abpframework/abp
  • Find module Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic (abp/modules/basic-theme/src/...) in the ABP repo.
  • Copy/Paste the module into your src folder of the project.

Comment out and replace in the Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.csproj

<!-- <Import Project="..\..\..\..\configureawait.props" /> -->
<Import Project="..\..\common.props" />

Comment out in Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.csproj

<!-- <ItemGroup>
    <ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.AspNetCore.Mvc.UI.MultiTenancy\Volo.Abp.AspNetCore.Mvc.UI.MultiTenancy.csproj" />
    <ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared\Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.csproj" />
  </ItemGroup> -->

Install ABP packages

Open a command prompt in the Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic and install ABP packages

    abp add-package Volo.Abp.AspNetCore.Mvc.UI.MultiTenancy
    abp add-package Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared

Change dotnet Target Framework

Open file Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.csproj and change dotnet target framework.

<!-- <TargetFramework>net6.0</TargetFramework> -->
<TargetFramework>net5.0</TargetFramework>

Build Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic project

Open a command prompt in the Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic project and run command below:

    dotnet build

Add reference to Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic

Open a command prompt in the HttpApi.Host project and add a reference to Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic in the HttpApi.Host.csproj file by running command below

   dotnet add reference ../../src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.csproj

Build the HttpApi.Host project

Open a command prompt in the HttpApi.Host project and run command below:

    dotnet build

Hide Tenant Switch in Account.cshtml file

Goto Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic\Themes\Basic\Themes\Basic\Layouts\Account.cshtml

Comment out if statement below to hide Tenant Switch.

@* @if (MultiTenancyOptions.Value.IsEnabled &&
(TenantResolveResultAccessor.Result?.AppliedResolvers?.Contains(CookieTenantResolveContributor.ContributorName)
== true ||
TenantResolveResultAccessor.Result?.AppliedResolvers?.Contains(QueryStringTenantResolveContributor.ContributorName)
== true)) {
<div class="card shadow-sm rounded mb-3">
    <div class="card-body px-5">
        <div class="row">
            <div class="col">
                <span style="font-size: .8em;" class="text-uppercase text-muted"
                    >@MultiTenancyStringLocalizer["Tenant"]</span
                ><br />
                <h6 class="m-0 d-inline-block">
                    @if (CurrentTenant.Id == null) {
                    <span> @MultiTenancyStringLocalizer["NotSelected"] </span>
                    } else {
                    <strong
                        >@(CurrentTenant.Name ??
                        CurrentTenant.Id.Value.ToString())</strong
                    >
                    }
                </h6>
            </div>
            <div class="col-auto">
                <a
                    id="AbpTenantSwitchLink"
                    href="javascript:;"
                    class="btn btn-sm mt-3 btn-outline-primary"
                    >@MultiTenancyStringLocalizer["Switch"]</a
                >
            </div>
        </div>
    </div>
</div>
}

Add ConfigureTenantResolver() method in HttpApiHostModule of HttpApi.Host project

Add method ConfigureTenantResolver right under the ConfigureServices method in the HttpApiHostModule class of the HttpApi.Host project

// import using statements
// using Volo.Abp.MultiTenancy;

private void ConfigureTenantResolver(ServiceConfigurationContext context, IConfiguration configuration)
{
    Configure<AbpTenantResolveOptions>(options =>
    {
        options.TenantResolvers.Clear();
        options.TenantResolvers.Add(new CurrentUserTenantResolveContributor());
});
}

Call ConfigureTenantResolver() method from ConfigureServices() method

public override void ConfigureServices(ServiceConfigurationContext context)
{
 // other code here ...

  ConfigureTenantResolver(context, configuration);
}

Add a Pages/Account folder to HttpApi.Host project

Add a CustomLoginModel.cs class to the Account folder

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Volo.Abp.Account.Web;
using Volo.Abp.Account.Web.Pages.Account;
using Volo.Abp.TenantManagement;
using IdentityUser = Volo.Abp.Identity.IdentityUser;

namespace AbpHideTenantSwitch.HttpApi.Host.Pages.Account
{
    public class CustomLoginModel : LoginModel
    {
        private readonly ITenantRepository _tenantRepository;

        public CustomLoginModel(IAuthenticationSchemeProvider schemeProvider, IOptions<AbpAccountOptions> accountOptions, IOptions<IdentityOptions> identityOptions, ITenantRepository tenantRepository) : base(schemeProvider, accountOptions, identityOptions)
        {
            _tenantRepository = tenantRepository;
        }

        public override async Task<IActionResult> OnPostAsync(string action)
        {
            var user = await FindUserAsync(LoginInput.UserNameOrEmailAddress);
            using (CurrentTenant.Change(user?.TenantId))
            {
                return await base.OnPostAsync(action);
            }
        }

        protected virtual async Task<IdentityUser> FindUserAsync(string uniqueUserNameOrEmailAddress)
        {
            IdentityUser user = null;
            using (CurrentTenant.Change(null))
            {
                user = await UserManager.FindByNameAsync(LoginInput.UserNameOrEmailAddress) ??
                       await UserManager.FindByEmailAsync(LoginInput.UserNameOrEmailAddress);

                if (user != null)
                {
                    return user;
                }
            }

            foreach (var tenant in await _tenantRepository.GetListAsync())
            {
                using (CurrentTenant.Change(tenant.Id))
                {
                    user = await UserManager.FindByNameAsync(LoginInput.UserNameOrEmailAddress) ??
                           await UserManager.FindByEmailAsync(LoginInput.UserNameOrEmailAddress);

                    if (user != null)
                    {
                        return user;
                    }
                }
            }
            return null;
        }
    }
}

Add a Login.cshtml file to the Account folder

@page @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *,
Volo.Abp.AspNetCore.Mvc.UI @addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap
@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling @using
Microsoft.AspNetCore.Mvc.Localization @using Volo.Abp.Account.Localization
@using Volo.Abp.Account.Settings @using Volo.Abp.Settings @model
AbpHideTenantSwitch.HttpApi.Host.Pages.Account.CustomLoginModel @inject
IHtmlLocalizer<AccountResource>
    L @inject Volo.Abp.Settings.ISettingProvider SettingProvider

    <div class="card text-center mt-3 shadow-sm rounded">
        <div class="card-body abp-background p-5">
            <div class="form-group">
                <img
                    src="https://raw.githubusercontent.com/bartvanhoey/AbpHideTenantSwitch/main/abp/~/assets/images/thumbs-up.png"
                    alt="ThumbsUp"
                    width="100%"
                />
            </div>
            @if (Model.EnableLocalLogin) {
            <form method="post" class="mt-4 text-left">
                <input asp-for="ReturnUrl" />
                <input asp-for="ReturnUrlHash" />
                <div class="form-group">
                    <label>Email address</label>
                    <input
                        asp-for="LoginInput.UserNameOrEmailAddress"
                        class="form-control"
                    />
                    <span
                        asp-validation-for="LoginInput.UserNameOrEmailAddress"
                        class="text-danger"
                    ></span>
                </div>
                <div class="form-group">
                    <label asp-for="LoginInput.Password"></label>
                    <input asp-for="LoginInput.Password" class="form-control" />
                    <span
                        asp-validation-for="LoginInput.Password"
                        class="text-danger"
                    ></span>
                </div>
                <abp-button
                    type="submit"
                    button-type="Danger"
                    name="Action"
                    value="Login"
                    class="btn-block btn-lg mt-3"
                    >@L["Login"]</abp-button
                >
                @if (Model.ShowCancelButton) {
                <abp-button
                    type="submit"
                    button-type="Secondary"
                    formnovalidate="formnovalidate"
                    name="Action"
                    value="Cancel"
                    class="btn-block btn-lg mt-3"
                    >@L["Cancel"]</abp-button
                >
                }
            </form>
            }
        </div>
    </div></AccountResource
>

Custom styles Login page

Add a custom-login-styles.css file to the wwwroot folder of the HttpApi.Host project

.abp-background { background-color: yellow !important; }

Add custom styles to Bundle

Open file AbpHideTenantSwitchHttpApiHostModule.cs of the HttpApi.Host project and add custom-login-styles.css to bundle.

private void ConfigureBundles()
   {
     Configure<AbpBundlingOptions>(options =>
     {
       options.StyleBundles.Configure(
                 BasicThemeBundles.Styles.Global,
                 bundle =>
             {
               bundle.AddFiles("/global-styles.css");
               bundle.AddFiles("/custom-login-styles.css");
             }
             );
     });
   }

Add an Image

Add an assets/images folder to the wwwroot folder of the HttpApi.Host project and copy/paste an image into images folder.

Start both the Blazor and the HttpApi.Host project to run the application

Et voilà! The Login page without a Tenant switch!

Login page without tenant switch

A user can now login with email address and username without specifying the tenant name.

Get the source code on GitHub.

Enjoy and have fun!

Halil İbrahim Kalkan 58 weeks ago

Thanks for the article submission.

CustomLoginModel.cs loops though the tenants. Could we disable the multi-tenancy filter, then query once. I know that doesn't work for "database per tenant" approach, but can be a good option if we use a single/shared database for all tenants.

Halil İbrahim Kalkan 58 weeks ago

You can use abp add-module Volo.BasicTheme --with-source-code --add-to-solution-file command instead of manually downloading and replacing the Basic Theme. However, it can be possible to override all these without requiring to have the source code. See https://community.abp.io/articles/how-to-customize-the-login-page-for-mvc-razor-page-applications-9a40f3cd

Don Vliet 57 weeks ago

What would happen if the same user exists in more than one tenant using the same username and password for all tenants?

Also, if we have the following: Tenant A - Username: Darren & Password: ABC --- Tenant B - Username: Darren & Password: DEF --- Tenant C - Username: Darren & Password: GHI ---

If the user enters Username/Password as Darren / GHI - would the code find the first instance of Darren in Tenant A, and then try and login as Darren / GHI in Tenant A ?

bushbert 55 weeks ago

Blazor needs support for the Domainresolver really badly.

Nicolas Nicolas 56 weeks ago

There is my feedback on using this solution with our mvc project.

  1. When all the tenant resolver are cleared and only CurrentUserTenantResolveContributor is left the tenant switch is automatically hided. So no need to override the login page.

  2. The change of current tenant ID set not the current tenant name. This resulting to have securitylogs without tenantname.

Beside this two things this solution works like a charm. Thanks for the sharing.

Adam Gilmore 25 weeks ago

This method works for Blazor 5.2.0 Commercial version.

First remove the "select tenant" option from the login form. In the <appname>HttpAPIHostModule class in the HttpApi.Host project add the following to the ConfigureServices method.

        // Clear the Tenant Resolvers for tenantless login
        context.Services.Configure&lt;AbpTenantResolveOptions&gt;(options =>
        {
            options.TenantResolvers.Clear();
            options.TenantResolvers.Add(new CurrentUserTenantResolveContributor());
        });

Create a Pages then Account folder in the HttpApi.Host project. Add a class called CustomLoginModel.cs

Use the following code:

using IdentityServer4.Services; using IdentityServer4.Stores; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Volo.Abp.Account.Web.Pages.Account; using Volo.Abp.DependencyInjection; using IdentityUser = Volo.Abp.Identity.IdentityUser; using Volo.Abp.Account.Public.Web.Pages.Account; using Volo.Abp.Account.Public.Web; using Volo.Abp.Account.ExternalProviders; using Volo.Abp.Account.Security.Recaptcha; using Volo.Abp.Security.Claims; using Owl.reCAPTCHA; using Volo.Abp.Data; using Volo.Abp.MultiTenancy;

namespace *.App.Pages.Account { [ExposeServices(typeof(LoginModel))] public class CustomLoginModel : IdentityServerSupportedLoginModel { public IDataFilter DataFilter { get; set; }

    public CustomLoginModel(
        IAuthenticationSchemeProvider schemeProvider,
        IOptions&lt;AbpAccountOptions&gt; accountOptions,
        IAccountExternalProviderAppService accountExternalProviderAppService,
        IIdentityServerInteractionService interaction,
        IClientStore clientStore,
        IEventService identityServerEvents,
        ICurrentPrincipalAccessor currentPrincipalAccessor,
        IAbpRecaptchaValidatorFactory recaptchaValidatorFactory,
        IOptions&lt;IdentityOptions&gt; identityOptions,
         IOptionsSnapshot&lt;reCAPTCHAOptions&gt; reCaptchaOptions)
        : base(schemeProvider, accountOptions, accountExternalProviderAppService, interaction, clientStore, identityServerEvents,
              currentPrincipalAccessor, recaptchaValidatorFactory, identityOptions, reCaptchaOptions)
    {

    }

    public override async Task&lt;IActionResult&gt; OnPostAsync(string action)
    {
        var user = await FindUserAsync(LoginInput.UserNameOrEmailAddress);
        using (CurrentTenant.Change(user?.TenantId))
        {
            return await base.OnPostAsync(action);
        }
    }

    public override async Task&lt;IActionResult&gt; OnGetExternalLoginCallbackAsync(string returnUrl = "", string returnUrlHash = "", string remoteError = null)
    {
        var user = await FindUserAsync(LoginInput.UserNameOrEmailAddress);
        using (CurrentTenant.Change(user?.TenantId))
        {
            return await base.OnGetExternalLoginCallbackAsync(returnUrl, returnUrlHash, remoteError);
        }
    }

    protected virtual async Task&lt;IdentityUser&gt; FindUserAsync(string uniqueUserNameOrEmailAddress)
    {
        IdentityUser user = null;

        using (DataFilter.Disable&lt;IMultiTenant&gt;())
        {
            user = await UserManager.FindByEmailAsync(LoginInput.UserNameOrEmailAddress);
        }
        if (user != null)
        {
            return user;
        }
        return null;
    }
}

}

I only allow login by email. DataFilter.Disable<IMultiTenant> disables MultiTenancy. The FindByEmailASync method queries the whole User table, not just the records for the current tenant. The DataFilter is injected using Property (rather than constructor) injection.

barrett2474 23 weeks ago

Adam - this is great - no need to rewrite all the UI! Works with 5.3 Commercial EF Blazor WASM

NickB 23 weeks ago

I'm new to ABP, I love the ideas and concepts. However just a philosophical question. Rather than injecting the ITenantRepository, should you be injecting the ITenantAppService. By using the repository directly are you breaking some dependency rule (I dont know just trying to understand best practice) Thanks Nick

[email protected] 18 weeks ago

Thanks for spreading a fruitful awareness about the Microsoft product in such a good way.

[email protected] 1 week ago

How do we do this on Blazer WASM Framework?

[email protected] 1 week ago

How do we do this on Blazer WASM Framework?