Hi,
Confirmed — SwitchTenant is registered with the OpenIddict server but its handler is missing from the ABP token-grant dispatcher, that's why you see "not implemented".
Please add the following to your AuthServer module's ConfigureServices as a workaround:
using Volo.Abp.Account.Web.ExtensionGrants;
using Volo.Abp.OpenIddict.ExtensionGrantTypes;
public override void ConfigureServices(ServiceConfigurationContext context)
{
// ... existing code ...
Configure<AbpOpenIddictExtensionGrantsOptions>(options =>
{
options.Grants[SwitchTenantExtensionGrant.ExtensionGrantName] = new SwitchTenantExtensionGrant();
});
}
After this, the SwitchTenant flow will dispatch correctly. We'll ship a permanent fix on our side in the next ABP release.
Thanks
Hi,
This is by design, not a bug. Adding a NuGet <PackageReference> is not the same as installing a module on the solution from ABP Suite's perspective — Suite only auto-loads entities from modules that were registered via "Install module on solution".
For custom modules, Suite provides an official extension point: the .suite/module-entity-extension.json file at the root of your module solution. Add the Domain DLL file names of the ABP modules you want to expose as navigation property targets. Example:
{
"Modules": [
{ "DomainProjectDllFileName": "Volo.Abp.Identity.Pro.Domain.dll" }
]
}
Then in the Add Navigation Property dialog, check the Include entities from ABP modules checkbox — IdentityUser (and other entities from the listed DLLs) will appear in the dropdown. Suite will automatically build the solution before scanning, so you don't need to build manually, but the DLL has to physically exist somewhere under <solutionRoot>/src/**/bin/... for Assembly.LoadFrom to pick it up. If your module solution has no host project that copies NuGet dependencies to bin, this method won't find the DLL.
If the DLL approach doesn't work for your project layout, the always-working fallback is to add the navigation property manually — type the target type (Volo.Abp.Identity.IdentityUser), FK property (Guid UserId), display property, etc. directly in the dialog without picking from the dropdown.
Docs:
Thanks
Hi,
Glad the NoSmtpIdentityEmailSender part is working.
The SwitchTenant error is unrelated to UserSharingStrategy = Shared — that switch is correct and required, but the actual blocker is that the Angular OpenIddict application doesn't have the gt:SwitchTenant permission in the database.
You probably added SwitchTenant to ExtensionGrantTypes in the OpenIddict admin UI, but the next DbMigrator run wipes it out. Look at OpenIddictDataSeeder.cs in your DbMigrator project — CreateApplicationAsync does this:
if (!HasSameScopes(client, application))
{
client.Permissions = JsonSerializer.Serialize(application.Permissions.Select(q => q.ToString()));
await _applicationManager.UpdateAsync(client.ToModel());
}
HasSameScopes actually compares the full Permissions JSON, so any permission you add manually in the UI (like gt:SwitchTenant) makes the comparison fail and the seeder overwrites the whole permission list with what's defined in code.
Fix: add "SwitchTenant" to the Angular client's grantTypes list in OpenIddictDataSeeder.cs, then re-run DbMigrator:
//Angular Client
await CreateApplicationAsync(
name: "Angular",
type: OpenIddictConstants.ClientTypes.Public,
consentType: OpenIddictConstants.ConsentTypes.Implicit,
displayName: "Angular Client",
secret: null,
grantTypes: new List<string> {
OpenIddictConstants.GrantTypes.AuthorizationCode,
OpenIddictConstants.GrantTypes.RefreshToken,
OpenIddictConstants.GrantTypes.Password,
"LinkLogin",
"Impersonation",
"SwitchTenant" // <-- add this
},
scopes: ...,
...
);
After DbMigrator runs, the OpenIddictApplicationPermissions table for the Angular client should contain a row gt:SwitchTenant. Then the Switch tenant action in Angular will succeed.
You can also add new grant types to an application directly from the OpenIddict admin UI (OpenIddict → Applications → edit → ExtensionGrantTypes). It's persisted to the database immediately, but as shown above it will be wiped on the next DbMigrator run unless you keep OpenIddictDataSeeder.cs in sync. So use the UI for quick experiments, and update the seeder for the permanent fix.
Thanks
Hi,
Sorry to hear about the trouble. To narrow this down on 8.3.0 we'll need a bit more info from your side:
Volo.Abp.BackgroundJobs store, Hangfire, Quartz, or RabbitMQ?BackgroundJobOptions configuration — anything you've customized (e.g. IsJobExecutionEnabled, custom JobExecutionTimeout, DefaultTimeout)?AbpBackgroundJobs table — a snapshot of a few stuck rows showing IsAbandoned, NextTryTime, TryCount, LastTryTime would help a lot.Error / Warning level from Volo.Abp.BackgroundJobs.* or Volo.Abp.DistributedLocking.*.Debug level logging for the background job namespaces and capture a few minutes of output while jobs are sitting in the queue. Steps here: https://abp.io/support/questions/8622/How-to-enable-Debug-logs-for-troubleshoot-problemseA few common causes we've seen for this exact symptom:
IsJobExecutionEnabled = false got set, or only the API host is running and the job host isn't).IAbpDistributedLock) is stuck — leftover lock row in DB / Redis that never expires, blocking the periodic queue poller.AbpBackgroundJobs table preventing the poller from reading the queue.IsAbandoned = true after exceeding MaxTryCount (default 3), which is why "previously failing" jobs stopped retrying.If you can share the logs and a sample of the AbpBackgroundJobs rows, we can dig in from there.
Thanks
Hi,
Yes, you can keep using AutoMapper. Starting from ABP 10.3.0, we shipped a new package Volo.Abp.LuckyPenny.AutoMapper exactly for this case — it's a drop-in replacement built on the commercial AutoMapper (15.x+) by LuckyPenny Software, which is the patched version (the free 14.x has a DoS vulnerability that won't be fixed).
Migration is just two steps in your own modules:
// *.csproj
-<PackageReference Include="Volo.Abp.AutoMapper" />
+<PackageReference Include="Volo.Abp.LuckyPenny.AutoMapper" />
// YourModule.cs
-[DependsOn(typeof(AbpAutoMapperModule))]
+[DependsOn(typeof(AbpLuckyPennyAutoMapperModule))]
All the types stay in the same namespaces (AbpAutoMapperOptions, IMapperAccessor, etc.), so your existing Profiles and CreateMap calls keep working as-is. License key goes through AbpAutoMapperOptions.Configurators:
Configure<AbpAutoMapperOptions>(options =>
{
options.Configurators.Add(ctx =>
{
ctx.MapperConfiguration.LicenseKey = "YOUR_LICENSE_KEY";
});
});
Note: Volo.Abp.LuckyPenny.AutoMapper and Volo.Abp.AutoMapper are mutually exclusive — pick one.
About Text Template Management (and other ABP modules migrated to Mapperly): you can use them just fine alongside AutoMapper in your own code. The Mapperly usage is internal to those modules — you don't need to write any Mapperly mappers yourself. The only thing you can't do is fully remove Mapperly from the solution, since the module assemblies are compiled against it. But it's just a transitive dependency, not something you have to interact with.
So the supported setup is:
Volo.Abp.LuckyPenny.AutoMapper if you hold a commercial license)Docs: https://abp.io/docs/latest/framework/infrastructure/luckypenny-automapper
Thanks
Hi,
A couple of things to clear up first:
TenantId" trick won't work — the tenant admin lives in the tenant DB (Isolated mode) and just changing TenantId on a host user breaks the multi-tenant integrity.SwitchTenant grant only works when AbpMultiTenancyOptions.UserSharingStrategy == Shared. In Isolated mode the grant handler explicitly returns unsupported_grant_type, so adding it on the Angular OpenIddict client has no effect — please remove SwitchTenant from the Angular app's ExtensionGrantTypes.For your "no SMTP allowed" scenario, the cleanest workaround is to replace the OOTB IIdentityEmailSender so the existing "Invite admin" button never calls SMTP. Instead, the accept-link is written to the log (or your own table / webhook / Redis — whatever your ops team can read). The host admin grabs the link from there and forwards it to the invitee through any internal channel (Slack/Teams/internal portal). The invitee opens the link, the OOTB Account/InviteUser page loads, they pick a username/password and accept.
Zero UI change, zero new endpoints, no client-proxy regeneration in layered solutions. The OOTB "Invite admin" button keeps working unchanged in MVC, Blazor, and Angular.
[Dependency(ReplaceServices = true)]
[ExposeServices(typeof(IIdentityEmailSender))]
public class NoSmtpIdentityEmailSender : IdentityEmailSender
{
public NoSmtpIdentityEmailSender(
IEmailSender emailSender,
ITemplateRenderer templateRenderer,
IStringLocalizer<IdentityResource> stringLocalizer,
IAppUrlProvider appUrlProvider,
UserSharingManager userSharingManager,
UserInvitationManager userInvitationManager)
: base(emailSender, templateRenderer, stringLocalizer, appUrlProvider,
userSharingManager, userInvitationManager)
{
}
public override async Task SendInviteEmailAsync(UserInvitation invitation, bool requireRegister, string appName)
{
var url = await AppUrlProvider.GetInviteUserUrlAsync(appName);
var token = await UserInvitationManager.GenerateTokenFromInvitationIdAsync(invitation.Id);
var link = $"{url}?token={UrlEncoder.Default.Encode(token)}";
Logger.LogWarning(
"[Invite] Tenant={tenantId} Email={email} RequireRegister={requireRegister} Link={link}",
invitation.InviterTenantId, invitation.InviteeEmail, requireRegister, link);
// Or persist to your own table / push to a webhook / write to Redis -
// whatever your ops team can read from to retrieve the link.
}
}
Drop this class into your Application module. Each invite produces a log line like:
[Invite] Tenant=... Email=admin@mycorp.com RequireRegister=True Link=https://your-app/Account/InviteUser?token=CfDJ8...
Just copy the Link=... value and forward it to the invitee. Done — no SMTP, no SwitchTenant, no host-user copying.
Thanks
Hi,
The "Switch Tenant" feature relies on a custom OpenIddict extension grant (SwitchTenant) plus a few /api/account/user-sharing endpoints. Here is everything you need to wire it up in Next.js.
Read multiTenancy.userSharingStrategy from the application configuration endpoint. Only show the "Switch tenant" UI when it equals 1 (Shared).
GET {HttpApiHost}/api/abp/application-configuration
{
"multiTenancy": {
"isEnabled": true,
"userSharingStrategy": 1 // 0 = Isolated, 1 = Shared
}
}
GET {HttpApiHost}/api/account/user-sharing
Authorization: Bearer <current_access_token>
Response:
{
"items": [
{ "tenantId": "guid-or-null", "tenantName": "TenantA", "email": "user@x.com" },
{ "tenantId": "guid-or-null", "tenantName": "TenantB", "email": "user@x.com" }
]
}
SwitchTenant grant typeThis is the key part. Post to the AuthServer token endpoint with grant_type=SwitchTenant. The current access_token is sent as a parameter (the grant handler introspects it to identify the user) and a new token set scoped to the target tenant is returned.
POST {AuthServerUrl}/connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=SwitchTenant
&client_id=<your_client_id>
&client_secret=<your_client_secret> // only for confidential clients
&access_token=<current_valid_access_token>
&TenantId=<target_tenant_guid> // omit / leave empty to switch to Host
Response is a standard OAuth token response:
{
"access_token": "...",
"refresh_token": "...",
"id_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "..."
}
The new access_token already has the target tenant's tenantid claim, so every subsequent API call will run in the new tenant context.
After receiving the response in Next.js you should:
access_token / refresh_token / id_tokenThis is exactly what Angular does — see SwitchTenantService in commercial-ui/config/src/services/switch-tenant.service.ts, which calls loginUsingGrant('SwitchTenant', { TenantId }) and then location.href = baseUrl to refresh.
DELETE {HttpApiHost}/api/account/user-sharing/leave?tenantId=<guid>
Authorization: Bearer <current_access_token>
GET /api/account/user-sharing/invitation?token=<inviteToken>
POST /api/account/user-sharing/invitation/accept // body: { token, ... }
POST /api/account/user-sharing/invitation/reject // body: { token, ... }
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});
If UserSharingStrategy is not Shared, the SwitchTenant grant returns unsupported_grant_type.
GET /api/abp/application-configuration → check multiTenancy.userSharingStrategy === 1GET /api/account/user-sharing → render the tenant listPOST /connect/token with grant_type=SwitchTenant&access_token=<current>&TenantId=<guid>Thanks
Hi @rcalv002,
Thanks for letting us know. We will continue working on the Elsa module, and any updates will be included in the release notes for upcoming versions.
Thanks
Hi,
Can you replace the contents of the inline workaround script block in your _Host.cshtml with this (the surrounding script tag and the rest of the page stay as-is):
// LeptonX 5.3.0 side-menu rebind workaround.
// initSideMenu runs once from SideMenuLayout.OnAfterRenderAsync and may
// execute against an empty #lpx-sidebar (when MainMenuProvider yields) or
// be skipped on impersonation rebuild. We watch the sidebar and, whenever
// an outer menu item ends up without click listeners, remove every
// previously attached listener (so we never double-bind a preserved
// element) and call initSideMenu once more to re-attach a single clean
// set. window.initLeptonX is wrapped so the natural call from
// OnAfterRenderAsync goes through the same cleanup path.
(function () {
try {
const allBinds = new WeakMap();
let timer = null;
const origAdd = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
if (this instanceof Element
&& this.classList && this.classList.contains('lpx-menu-item')
&& this.closest && this.closest('#lpx-sidebar')) {
let arr = allBinds.get(this);
if (!arr) { arr = []; allBinds.set(this, arr); }
arr.push({ type: type, fn: fn, opts: opts });
}
return origAdd.call(this, type, fn, opts);
};
function cleanupBinds() {
document.querySelectorAll('#lpx-sidebar .lpx-menu-item').forEach(function (el) {
const arr = allBinds.get(el);
if (arr) {
arr.forEach(function (b) { el.removeEventListener(b.type, b.fn, b.opts); });
allBinds.delete(el);
}
});
}
const origInitLeptonX = window.initLeptonX;
window.initLeptonX = function () {
cleanupBinds();
return origInitLeptonX.apply(this, arguments);
};
function reinit() {
cleanupBinds();
const fn = window.leptonx
&& window.leptonx.init
&& window.leptonx.init.initializers
&& window.leptonx.init.initializers.get('initSideMenu');
if (fn) fn();
}
function check() {
const items = document.querySelectorAll('#lpx-sidebar .outer-menu-item > .lpx-menu-item');
if (items.length === 0) return;
let needs = false;
for (let i = 0; i < items.length; i++) {
if (!allBinds.has(items[i])) { needs = true; break; }
}
if (needs) reinit();
}
function debounced() { clearTimeout(timer); timer = setTimeout(check, 300); }
new MutationObserver(debounced).observe(document.body, { childList: true, subtree: true });
debounced();
} catch (err) {
console.error('[lpx-fix] failed:', err);
}
})();
A proper upstream fix is also queued for the next LeptonX patch release, so once you upgrade you can drop this whole inline script.
Thanks