Hi,
The latest stable is 10.4 (2026-05-14). 10.5 is currently in RC and the only CMS Kit change in it is the CodeMirror 6 update. The items you listed (SEO meta, media gallery, RSS, content versioning, social streams) are listed in the road map backlog under "Application Modules / UI Themes" but not assigned to a specific release yet (SEO meta has its own feature request at https://github.com/abpframework/abp/issues/16342). When they land, you'll receive them through a regular version upgrade.
On the Angular side, CMS Kit support was introduced in 10.2 ("Angular: CMS Kit Module Features" in the release notes) and has been stable since then. Angular support here refers to the admin UI — from 10.2 onwards it covers:
So if you move from 9.3.5 to 10.4 you'll get the full admin set above on Angular. The public-facing pages and widgets (blog list/detail, commenting, ratings, reactions, popular tags, newsletter subscription, etc.) are served by the MVC public-web app that the microservice template already includes — that's the public website you enabled with --public-website, where the CMS Kit MVC views and ViewComponents are wired up.
A note on the upgrade itself: since you're on 9.3.5 (.NET 9), the main step is the move to .NET 10 in 10.0. Run through the 9.x → 10.x migration guide first, then go to 10.4.
For the integration steps in a microservice solution, the official guide covers everything you need (CLI command, EF migrations, static proxy, gateway routes, and connection string mapping):
GlobalFeatureConfigurator)Thanks
Hi,
You're right, this is a Basic theme bug — its LoginDisplay renders every user-menu item as a plain <a href> and ignores the component attached via UseComponent(...), so the ImpersonationComponent (POST form) never gets rendered and the click hits OpenIddict as a GET → ID2029.
Until the fix ships, you can replace the toolbar item with your own. Add MyLoginDisplay.razor to your Blazor project:
@using Blazorise
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.Extensions.Localization
@using global::Localization.Resources.AbpUi
@using Volo.Abp.MultiTenancy
@using Volo.Abp.UI.Navigation
@using Volo.Abp.Users
@implements IDisposable
@inject IMenuManager MenuManager
@inject ICurrentUser CurrentUser
@inject ICurrentTenant CurrentTenant
@inject IStringLocalizer<AbpUiResource> L
@inject NavigationManager Navigation
<AuthorizeView>
<Authorized>
<Dropdown EndAligned="true">
<DropdownToggle Color="Color.Default">
@if (CurrentTenant.Name != null)
{
<span><i>@CurrentTenant.Name</i>\@CurrentUser.UserName</span>
}
else
{
<span>@CurrentUser.UserName</span>
}
</DropdownToggle>
<DropdownMenu>
@if (Menu != null)
{
@foreach (var menuItem in Menu.Items)
{
var componentType = menuItem.GetComponentTypeOrDefault();
if (componentType != null)
{
<DynamicComponent Type="@componentType" />
}
else
{
<a class="dropdown-item" href="@menuItem.Url?.TrimStart('/', '~')" target="@menuItem.Target">@menuItem.DisplayName</a>
}
}
}
</DropdownMenu>
</Dropdown>
</Authorized>
<NotAuthorized>
<a class="nav-link" href="Account/Login">@L["Login"]</a>
</NotAuthorized>
</AuthorizeView>
@code {
private ApplicationMenu? Menu;
protected override async Task OnInitializedAsync()
{
Menu = await MenuManager.GetAsync(StandardMenus.User);
Navigation.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
=> InvokeAsync(StateHasChanged);
public void Dispose() => Navigation.LocationChanged -= OnLocationChanged;
}
Then a contributor to swap it in:
using System.Threading.Tasks;
using Volo.Abp.AspNetCore.Components.Server.BasicTheme.Themes.Basic;
using Volo.Abp.AspNetCore.Components.Web.Theming.Toolbars;
public class CustomLoginDisplayContributor : IToolbarContributor
{
public Task ConfigureToolbarAsync(IToolbarConfigurationContext context)
{
if (context.Toolbar.Name == StandardToolbars.Main)
{
context.Toolbar.Items.RemoveAll(x => x.ComponentType == typeof(LoginDisplay));
context.Toolbar.Items.Add(new ToolbarItem(typeof(MyLoginDisplay)));
}
return Task.CompletedTask;
}
}
Register it in your Blazor module's ConfigureServices:
Configure<Volo.Abp.AspNetCore.Components.Web.Theming.Toolbars.AbpToolbarOptions>(options =>
{
options.Contributors.Add(new CustomLoginDisplayContributor());
});
We'll fix Basic theme itself in v10.4.2 so the workaround isn't needed.
Thanks
Hi,
By default ABP returns enums as numbers, not strings. We reproduced on a fresh ABP 10.3 Angular tiered project and /api/saas/tenants returned:
"activationState": 0
Not "Active". With this default response, the tenants page renders normally.
In your environment it returns "Active", which means something in your backend is overriding the default JSON behavior. The most common cause is a custom JsonStringEnumConverter registered somewhere — AddJsonOptions, Configure<JsonOptions> in your *.HttpApi.Host, or pulled in by a third-party module.
Could you share:
AddJsonOptions, Configure<JsonOptions>, JsonStringEnumConverter, Newtonsoft replacement, etc.GET /api/saas/tenants?skipCount=0&maxResultCount=10 from your environment.Removing the custom JSON configuration and letting ABP return enums as numbers should resolve the page crash without any other change.
Thanks
Hi,
The command abp get-source Volo.Abp.LeptonXTheme is correct — the earlier suggestion to use .Pro doesn't apply, there is no such package name.
The download fails because the CLI couldn't get a commercial API key for the account you're logged in with. Source code packages live on the commercial NuGet feed, and the CLI only adds that feed when it can pull the API key tied to a Developer seat. The most common cause is that the account isn't a Developer member of your GreenWave organization (being an Owner alone isn't enough).
To fix:
abp logout
abp login <your-username>
abp get-source Volo.Abp.LeptonXTheme
If it succeeds, you'll see a log line like ... downloaded from https://nuget.abp.io/<api-key>/v3/index.json.
About overriding the layouts once you have the source:
You don't need to modify the downloaded source itself. The LeptonX MVC theme uses ABP's Virtual File System, so you can override any layout or partial by placing a file with the same name under the same folder path in your MVC web project. Copy the file you want to customize from the downloaded source into your *.Web (or *.Web.Host) project root, keeping the same relative path, and edit it there.
The files you can override:
Themes/LeptonX/Layouts/Application/SideMenuLayout.cshtml
Themes/LeptonX/Layouts/Application/TopMenuLayout.cshtml
Themes/LeptonX/Layouts/Application/_Footer.cshtml
Themes/LeptonX/Layouts/Application/_Sidebar.cshtml
Themes/LeptonX/Layouts/Application/_Toolbar.cshtml
Themes/LeptonX/Layouts/Account/Default.cshtml
Themes/LeptonX/Layouts/Account/_Footer.cshtml
Reference: https://abp.io/docs/latest/ui-themes/lepton-x/mvc#layouts
Thanks
Hi,
The URL in the error message isn't meant for you to open — it points to an internal account that you don't have access to. So there's no link to click directly from that error.
The correct place to top up is the AI Credits section on your organization page:
#ai-credits anchor)AI credits are purchased per organization and shared across all developers in it. The balance updates automatically after payment, and ABP Studio will pick it up on the next request — you don't need to configure any API key yourself.
Thanks
Hi,
Quick correction on the table you tried: AbpPermissionGrants doesn't have an IsEnabled column. Each row is one grant, so revoking a permission means deleting the row. The IsEnabled column you saw belongs to AbpPermissions (the permission definitions store), which is only read at runtime when IsDynamicPermissionStoreEnabled = true (microservice template). Editing it on a standard app does nothing.
Menu items and setting tabs are controlled by features + permissions — either one being denied hides the item. You don't need to touch the database for any of this.
Three of the four items are feature-driven. You can toggle them from the SaaS admin UI:
| Item | Feature |
|---|---|
| Language management menu | LanguageManagement.Enable |
| Text templates menu | TextManagement.Enable (group name is TextManagement, not TextTemplateManagement) |
| Settings → Emailing tab | SettingManagement.AllowChangingEmailSettings |
Go to SaaS → Editions, click Features on the edition your tenants use, and toggle these three off. Every tenant assigned to that edition inherits the disabled features automatically — no per-tenant action needed. If you want to override per tenant, SaaS → Tenants has the same Features button on each row.
⚠️ Do not disable SettingManagement.Enable — that hides the entire Settings menu, not just the Emailing tab.
The Menu:Account tab is the only one without a feature, so it's permission-only. The simplest way to hide it from all tenants (current and future) is to override the permission so it only applies to the host. In your *.Application.Contracts project add:
public class CustomAccountPermissionDefinitionProvider : PermissionDefinitionProvider
{
public override void Define(IPermissionDefinitionContext context)
{
var p = context.GetPermissionOrNull("AbpAccount.SettingManagement");
if (p != null)
{
p.MultiTenancySide = MultiTenancySides.Host;
}
}
}
The data seeder filters permissions by MultiTenancySide, so once this is in place new tenants never get this grant in the first place. For tenants that already have the grant, delete it once (either re-run your seeder or use Login as tenant from SaaS → Tenants and uncheck Account → Setting Management on the tenant's admin role).
Thanks
Hi,
That makes sense — going with a host user + Tenant Only role gives you a stable seam that doesn't depend on the shadow-row Id behaviour or the user-validator side effects we were routing around in the previous reply.
Good news: for the tenant-listing and tenant-switching paths, the host-access policy doesn't need overrides at five separate entry points. The SelectTenant page, the Switch Tenant modal, the cookie-based SwitchTenantLogin POST, the OpenIddict SwitchTenant extension grant, and the IdentityServer SwitchTenant extension grant all route their authorization through just two methods on UserSharingManager:
IsTenantSharedWithUserAsync(email, targetTenantId) — used by everything that switches into a tenant by id (SwitchTenantLoginModel.OnPostAsync, both SwitchTenant extension grants)GetUserWithTenantsFromHostAsync(user, includeCurrentUser) — used by everything that lists the user's available tenants (SelectTenantModel.OnGetAsync / OnPostAsync, UserSharingAppService.GetAllListAsync)Override those two and the host-only rule is enforced at every entry point that asks the framework "is this user allowed into this tenant?" or "what tenants is this user in?".
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.DistributedLocking;
using Volo.Abp.Identity;
using Volo.Abp.Identity.UserInvitations;
using Volo.Abp.Identity.UserSharing;
using Volo.Abp.MultiTenancy;
namespace YourApp;
[Dependency(ReplaceServices = true)]
[ExposeServices(typeof(UserSharingManager))]
public class HostAccessRestrictedUserSharingManager : UserSharingManager
{
// Host-side role that marks a user as "must stay inside a tenant" — they can switch
// between the tenants they belong to but can never end up signed in as a host user.
// Replace with your own role name, your own permission check, or anything else that
// expresses your host-access policy.
public const string TenantOnlyRoleName = "Tenant Only";
public HostAccessRestrictedUserSharingManager(
IOptions<AbpMultiTenancyOptions> multiTenancyOptions,
IDataFilter dataFilter,
ITenantStore tenantStore,
IdentityProUserManager userManager,
IIdentityUserRepository userRepository,
IdentityRoleManager roleManager,
IIdentityRoleRepository roleRepository,
UserInvitationManager userInvitationManager,
IAbpDistributedLock distributedLock,
IConnectionStringResolver connectionStringResolver)
: base(multiTenancyOptions, dataFilter, tenantStore, userManager, userRepository,
roleManager, roleRepository, userInvitationManager, distributedLock,
connectionStringResolver)
{
}
// Returns true when the user is allowed to be signed in as a host user (i.e. with
// TenantId == null). Default policy: the host-side user is NOT in the "Tenant Only"
// role. If the user has no host record at all, there is no host login to grant, so
// the method returns false.
protected virtual async Task<bool> CanAccessHostAsync(string email)
{
if (email.IsNullOrWhiteSpace())
{
return false;
}
using (CurrentTenant.Change(null))
{
var hostUser = await UserManager.FindByEmailAsync(email);
if (hostUser == null)
{
return false;
}
return !await UserManager.IsInRoleAsync(hostUser, TenantOnlyRoleName);
}
}
public override async Task<bool> IsTenantSharedWithUserAsync(string email, Guid? targetTenantId)
{
if (targetTenantId == null && !await CanAccessHostAsync(email))
{
return false;
}
return await base.IsTenantSharedWithUserAsync(email, targetTenantId);
}
public override async Task<List<UserSharingInfo>> GetUserWithTenantsFromHostAsync(
IdentityUser user,
bool includeCurrentUser = false)
{
var tenants = await base.GetUserWithTenantsFromHostAsync(user, includeCurrentUser);
if (!await CanAccessHostAsync(user.Email))
{
tenants = tenants.Where(t => t.TenantId != null).ToList();
}
return tenants;
}
}
I verified this end-to-end against your reproduction setup (10.4, shared user accounts, separate tenant database). With the override registered and a test user that has both a tenant shadow row and a host-side row in the Tenant Only role:
Login page still calls ShouldSelectTenantAsync and decides to route through SelectTenant, but SelectTenantModel.OnGetAsync reads the filtered tenants list, sees a single entry, and auto-selects it. No Host choice is presented.UserSharingAppService.GetAllListAsync, which also goes through GetUserWithTenantsFromHostAsync. With the filter applied, the modal does not include a Host entry.SwitchTenantLogin POST and both SwitchTenant token grants (OpenIddict and IdentityServer) all delegate the membership check to IsTenantSharedWithUserAsync, so a request that targets TenantId == null for a Tenant Only user is rejected at the framework layer — there is no UI-only loophole.A few notes:
AbpUsers row with TenantId = null, link it to the Tenant Only role, and let ABP's existing distributed event handlers keep UserName / Email / PasswordHash in sync across the host row and the tenant row.SelectTenant page once. ShouldSelectTenantAsync uses GetUsersByEmailFromHostAsync (not the override target) and sees sharedUsers.Count > 1 because the host record + the tenant record both count, so the login flow routes the user to SelectTenant. The override only kicks in inside the page: GetUserWithTenantsFromHostAsync returns the filtered list, SelectTenantModel.OnGetAsync sees a single tenant entry, and auto-selects it. The end result is that the user lands inside their tenant — they just pass through a single SelectTenant hop on the way there. You don't need to also override ShouldSelectTenantAsync to make this work.LeaveAsync and other host-only management paths still go through UserSharingAppService and UserSharingManager; if you add more host-only flows, route them through the same CanAccessHostAsync policy so the rule stays in one place.CanAccessHostAsync is the one place to express it — every entry point above already routes through it.SwitchTenant extension grants). It does not override the initial sign-in itself. A couple of paths to be aware of:
Volo.Abp.Account.Public.Web.Areas.Account.Controllers.AccountController.Login (POST /api/account/login) calls PasswordSignInAsync directly and does not pass through ShouldSelectTenantAsync — FindSharedUserByEmail/Name will pick the host row first, so a tenant-only user would end up signed in as a host user. If your clients use this API endpoint, apply the same host-access check there as well (or block the host row from being chosen up-front).Login.cshtml.cs's IsLinkLogin path short-circuits to the linked-accounts redirect before ShouldSelectTenantAsync runs. If you use linked accounts, route them through the same policy.AbpUsers row you are a host user." We're keeping the host row for policy-management purposes but treating users in the Tenant Only role as "tenant-only" at the tenant listing / switching seams. If a future ABP version reshapes shared-mode tenant switching, this override will need a quick re-audit.Thanks
Hi,
Early @volo/ngx-lepton-x.core versions matched the menu item URL against the current URL with strict equality. So when the menu item is registered at /production-management/applications and the detail URL is /production-management/applications/view/{id}, the comparison fails and the item is not marked selected — and its parent group stays collapsed. That's exactly what your screenshots show.
We changed this in @volo/ngx-lepton-x.core v5.1.0: the match now treats a menu item as active when its link is an ancestor of the current URL on a path-segment boundary, so list vs list/view/:id is handled out of the box.
Could you check your package.json for the installed @volo/ngx-lepton-x.core version (and your ABP version)?
5.4.1, matching ABP 10.4.1) — that should fix it on its own.Applications menu item and the detail route, so we can check that both URLs share the same path prefix.Thanks
Hi,
Those administration menu items and setting tabs are not hardcoded — each one is registered with a permission, and they only render when the current user (or role) has it. Hiding them is just a matter of un-granting the corresponding permission, no code change needed.
Here is the mapping for the items you mentioned:
| Item | Permission |
|---|---|
| Language management menu | LanguageManagement.Languages |
| Text templates menu | TextTemplateManagement.TextTemplates |
| Settings → Emailing tab | SettingManagement.Emailing |
| Settings → Menu:Account tab | AbpAccount.SettingManagement |
All four are available on both host and tenant side, so host and tenants can be customized independently:
Identity management → Roles, open the admin role's Permissions tab, and uncheck whatever you want to hide.admin role. Each tenant can be configured independently.Could you try this and let us know if you run into any issue? For example a menu still showing after unchecking, or a permission you can't find on the Permissions tab — that would help us see what's actually blocking you.
Thanks
Hi,
The 3-project layout you see (HttpApi host + Contracts + Tests) is by design — the microservice template uses service_nolayers so each service stays small. The reasoning is here: https://abp.io/docs/latest/solution-templates/microservice/microservices#solution-structure-of-microservices
You can absolutely layer a microservice the same way ABP's built-in modules (Volo.Abp.PermissionManagement, Volo.Abp.Identity, etc.) are layered. For a microservice on EF Core you end up with these projects in the service folder:
*.Domain.Shared
*.Domain
*.Contracts ← acts as Application.Contracts in the microservice template
*.Application
*.HttpApi
*.HttpApi.Client
*.Web ← Razor Pages + menu + static resources (modular UI)
*.EntityFrameworkCore
* ← service host (the "API")
*.Tests
I built a working reference solution end-to-end (ProductService layered into all of the above, called through the Web Gateway, also consumed from a Razor Page via the dynamic IProductAppService proxy). Drop me an email at liming.ma@volosoft.com and I'll share the zip — use it as a template for the service folder layout.
ABP Studio → Add → New Module → Microservice, keep Enable integration checked. The wizard wires the new service into the AuthServer, Web Gateway, Identity's OpenIddictDataSeeder, the appsettings.json files, Helm charts, and the Solution Runner profile for you.
In the generated service folder, add the layer projects (*.Domain.Shared, *.Domain, *.Application, *.HttpApi, *.HttpApi.Client, *.EntityFrameworkCore, and *.Web if the service has UI). Copy the csproj + *Module.cs from the corresponding folders in the reference zip, then rename namespaces to match your solution.
Wire the host (the service "API"):
ProjectReference to the new layer projects in the host csproj.[DependsOn(...)].*DbContext, add DbSet<YourEntity> and call the ConfigureYourService() extension method (defined in *.EntityFrameworkCore) inside OnModelCreating.ConfigureAutoControllers() — it moves into the *.HttpApi module.EF Core migration: dotnet ef migrations add Added_YourEntity and dotnet ef database update.
To show a page for it in the web app:
Reference the new *.Web project and add [DependsOn(typeof(YourServiceWebModule))] to the web module — the page and menu show up automatically.
Add a per-service entry to the web app's appsettings.json RemoteServices:
"RemoteServices": {
"Default": { "BaseUrl": "http://localhost:<webgateway>" },
"YourService": { "BaseUrl": "http://localhost:<your-service>" }
}
The per-service entry is required because the dynamic proxy queries <BaseUrl>/api/abp/api-definition to discover endpoints, and the Web Gateway's default /api/abp/* route forwards to the Administration service, so it never returns your service's methods. Pointing the proxy at the service directly is the standard pattern.
Can I keep the simpler 3-project layout for some services and use the layered one for others? Yes — they coexist in the same solution. Reserve the layered structure for services with real business logic.
Do existing baseline services (Identity, Administration) need to be re-layered? No. They're already wired and tested. Layer only your own business services.
If anything doesn't work out while you're wiring this up, just reply here and I'll have a look. Sharing your project is the fastest way for us to debug — push it to a private GitHub repo and invite https://github.com/maliming.
Thanks