Hi,
The reorder is a default sort behavior change in the framework, not something you did. In v8.x the nav items service sorted strictly by order, so when several items share the same order (the three default items all use 100), Array.sort preserved their insertion order: languages | current-user | full-screen. From v10 the menu service routes through a shared sort function that breaks ties by id alphabetically, and the ids happen to be Theme.CurrentUserComponent < Theme.FullScreenComponent < Theme.LanguagesComponent, so you end up with the new order.
To restore the previous order without replacing any component, patch the three items via NavItemsService.patchItem:
import { Component, inject } from '@angular/core';
import { NavItemsService } from '@abp/ng.theme.shared';
import { eThemeLeptonComponents } from '@volo/abp.ng.theme.lepton';
@Component({
selector: 'app-root',
template: '<router-outlet />',
})
export class AppComponent {
private navItems = inject(NavItemsService);
constructor() {
this.navItems.patchItem(eThemeLeptonComponents.Languages, { order: 1 });
this.navItems.patchItem(eThemeLeptonComponents.CurrentUser, { order: 2 });
this.navItems.patchItem(eThemeLeptonComponents.FullScreen, { order: 3 });
}
}
The AppComponent constructor is the easiest place — it runs after the theme registers its default items, so the patches always find them.
About FontAwesome 5: we don't recommend pinning @abp/ng.theme.shared or @volo/abp.ng.theme.lepton to older versions to keep FA5, because the theme packages are designed to match the framework version and downgrading them breaks compatibility with other ABP Angular packages on 10.4. If you really want the FA5 look, the only safe option is to import FA5 stylesheet into your own styles.scss and override the icon classes used in the affected components with CSS — it's doable but maintenance-heavy and we don't actively support it.
Thanks
Hi,
ABP picks the default culture from the Abp.Localization.DefaultLanguage setting (default value is "en"). For modules to land on Vietnamese by default, two things need to happen:
Add vi (or vi-VN) to the enabled languages from Language Management → Languages, with Enabled = true. Without this, vi isn't in RequestLocalizationOptions.SupportedCultures and the middleware won't accept it.
Set the default to vi. The simplest way is appsettings.json:
"Settings": {
"Abp.Localization.DefaultLanguage": "vi"
}
Values under the Settings: section are picked up by ABP's ConfigurationSettingValueProvider automatically. If you prefer, you can change the same setting from Administration → Settings → General (host or per-tenant), or programmatically via ISettingManager.SetGlobalAsync(LocalizationSettingNames.DefaultLanguage, "vi").
After that, requests without an explicit language preference land on vi, ABP modules use Vietnamese, and the DynamicResourceLocalizer override from the previous reply resolves those keys from your TenantId = null rows.
Thanks
Hi,
The fix is now merged and will ship in v10.5.0 — Account Pro's profile picture page uses a localized button + read-only label instead of the native input, on both Angular and MVC.
Thanks
Hi,
Glad login is working. The missing host "root" record isn't actually what's breaking things, so let me clear that up first and then go through your points.
A user who was only ever invited (never self-registered) has no TenantId = null/Guid.Empty root row — just one host "association" row per tenant, plus the tenant-DB copy. That's a supported shape: switching tenants and listing them do not need a root, and you don't need to backfill one. (A root is only created by self-registration, and it's stored with TenantId = Guid.Empty, not SQL NULL — that's why your WHERE TenantId IS NULL query finds nothing.) The host association row and the tenant-DB row are deliberately separate records with different Ids, matched by username/email — that part is by design too.
What's actually behind the EntityNotFoundException / empty permissions / 404s is the token's sub. In your override you resolve the user with GetUsersByUserNameFromHostAsync, which returns the host association row, so the token's sub becomes the host Id — and the tenant database doesn't contain that Id, so GetByIdAsync(sub) throws and the dynamic claims come back empty. Switch the override to FindSharedUserByNameAsync / FindSharedUserByEmailAsync (that's what the override we sent earlier used): they resolve the host row, switch into the user's tenant, and return the tenant-DB user, so the sub is the tenant-DB Id and the claims and sub-based endpoints resolve. That single change clears the first problem.
On the rest:
Switch Tenant "not implemented" — that's a known bug that's fixed: the SwitchTenant grant was registered with the OpenIddict server but missing from the extension-grants options, so the token endpoint reported "not implemented". Upgrade to a current 10.4.x that includes the fix and your manual Grants.TryAdd(...) won't be needed (tell us your exact patch and we'll confirm it's in there). After a switch the token is issued for the target tenant, so its sub is that tenant's user — the Id consistency you're after.
GET /api/account/user-sharing returning one tenant — that endpoint lists the other tenants you can switch into; it excludes the one you're currently signed into. So a user in two tenants, signed into one, gets the other one back (combine it with the current tenant for the full set). Once the token sub is the tenant-DB user from the fix above, this lines up. If you still think a row is missing, send the host-DB result of SELECT Id, TenantId, IsActive, Leaved FROM AbpUsers WHERE NormalizedEmail = '<UPPERCASE EMAIL>' and we'll check.
Per-tenant roles — roles are per tenant, on the tenant-side user row. The SaaS Invite User flow assigns the admin role; for a custom guest role like "External QC", either pass the roles you want through the Identity user-invitation, or assign the role inside that tenant after the user accepts.
On versions: several Shared User Accounts fixes landed after 10.3.0 (the Switch Tenant one included), so upgrading to the latest 10.4.x is worth doing. The password-grant lookup itself we'll fix in the framework in an upcoming release; until that ships, keep the FindSharedUserByNameAsync-based override.
Thanks
Hi,
There's no built-in "route → provider" switch in ABP, but all the pieces are there. The pattern below is one custom LoginModel plus a small URL-rewrite middleware. A couple of subtleties to call out up front:
/external/local should not invoke Challenge("local") — local is not a registered scheme. The override below special-cases it and falls through to the standard login page.GetExternalProviders below handles that.[ExposeServices(typeof(LoginModel))] and [Dependency(ReplaceServices = true)] are required to fully replace the default LoginModel.LoginModelusing System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Owl.reCAPTCHA;
using Volo.Abp.Account;
using Volo.Abp.Account.ExternalProviders;
using Volo.Abp.Account.Public.Web;
using Volo.Abp.Account.Public.Web.Pages.Account;
using Volo.Abp.Account.Security.Recaptcha;
using Volo.Abp.Account.Web.Pages.Account;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
using Volo.Abp.OpenIddict;
using Volo.Abp.Security.Claims;
using Volo.Saas.Tenants;
namespace MyCompanyName.MyProjectName.Pages.Account;
[Dependency(ReplaceServices = true)]
[ExposeServices(typeof(LoginModel))]
public class MyLoginModel : OpenIddictSupportedLoginModel
{
public string UserType { get; private set; }
public string Provider { get; private set; }
protected ITenantRepository TenantRepository { get; }
public MyLoginModel(
IAuthenticationSchemeProvider schemeProvider,
IOptions<AbpAccountOptions> accountOptions,
IAbpRecaptchaValidatorFactory recaptchaValidatorFactory,
IAccountExternalProviderAppService accountExternalProviderAppService,
ICurrentPrincipalAccessor currentPrincipalAccessor,
IOptions<IdentityOptions> identityOptions,
IOptionsSnapshot<reCAPTCHAOptions> reCaptchaOptions,
AbpOpenIddictRequestHelper openIddictRequestHelper,
ITenantRepository tenantRepository)
: base(schemeProvider, accountOptions, recaptchaValidatorFactory,
accountExternalProviderAppService, currentPrincipalAccessor,
identityOptions, reCaptchaOptions, openIddictRequestHelper)
{
TenantRepository = tenantRepository;
}
public override async Task<IActionResult> OnGetAsync()
{
UserType = RouteData.Values["userType"]?.ToString()
?? Request.Query["userType"].ToString();
Provider = RouteData.Values["provider"]?.ToString()
?? Request.Query["provider"].ToString();
// /tenant1/external/local → render the standard local login page; do not challenge.
if (string.Equals(Provider, "local", System.StringComparison.OrdinalIgnoreCase))
{
return await base.OnGetAsync();
}
if (!string.IsNullOrWhiteSpace(Provider))
{
if (!await IsProviderAllowedForCurrentTenantAsync(Provider, UserType))
{
Alerts.Danger(L["The provider is not allowed for this entry point."]);
return await base.OnGetAsync();
}
return await OnPostExternalLogin(Provider);
}
return await base.OnGetAsync();
}
public override async Task<IActionResult> OnPostExternalLogin(string provider)
{
var redirectUrl = Url.Page("./Login",
pageHandler: "ExternalLoginCallback",
values: new { ReturnUrl, ReturnUrlHash, LinkTenantId, LinkUserId, LinkToken });
var properties = SignInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
properties.Items["scheme"] = provider;
// Bind UserType into the auth properties so the callback can validate it.
if (!string.IsNullOrWhiteSpace(UserType))
{
properties.Items["UserType"] = UserType;
}
if (CurrentTenant.Id.HasValue)
{
properties.Items[TenantResolverConsts.DefaultTenantKey] = CurrentTenant.Id.ToString();
}
return Challenge(properties, provider);
}
public override async Task<IActionResult> OnGetExternalLoginCallbackAsync(string remoteError = null)
{
var loginInfo = await SignInManager.GetExternalLoginInfoAsync();
if (loginInfo != null)
{
string userType = null;
loginInfo.AuthenticationProperties?.Items.TryGetValue("UserType", out userType);
// If you require all external callbacks to come from a URL-driven entry point,
// reject when UserType is missing.
if (string.IsNullOrWhiteSpace(userType))
{
Alerts.Danger(L["External login must be initiated from a tenant entry point."]);
return await InitPageAsync() ?? Page();
}
if (!await IsProviderAllowedForCurrentTenantAsync(loginInfo.LoginProvider, userType))
{
Alerts.Danger(L["The provider is not allowed for this entry point."]);
return await InitPageAsync() ?? Page();
}
// Optionally promote UserType to a user claim / extra property here.
}
return await base.OnGetExternalLoginCallbackAsync(remoteError);
}
// Hide non-whitelisted external providers on the standard login page (used when /external/local is opened).
protected override async Task<List<ExternalProviderModel>> GetExternalProviders()
{
var providers = await base.GetExternalProviders();
if (string.IsNullOrWhiteSpace(UserType))
{
return providers;
}
var result = new List<ExternalProviderModel>();
foreach (var p in providers)
{
if (await IsProviderAllowedForCurrentTenantAsync(p.AuthenticationScheme, UserType))
{
result.Add(p);
}
}
return result;
}
protected virtual async Task<bool> IsProviderAllowedForCurrentTenantAsync(string provider, string userType)
{
if (!CurrentTenant.Id.HasValue || string.IsNullOrWhiteSpace(userType))
{
return true; // host, or no UserType bound → don't restrict
}
// Tenant entity lives in host scope; switch to host before querying (same pattern ABP's own TenantStore uses).
var tenantId = CurrentTenant.Id.Value;
Tenant tenant;
using (CurrentTenant.Change(null))
{
tenant = await TenantRepository.FindAsync(tenantId);
}
if (tenant == null)
{
return false;
}
// ExtraProperties may hold the value as a JSON string or as a nested object — handle both.
var raw = tenant.ExtraProperties.GetOrDefault("AllowedProviders");
if (raw == null)
{
return false;
}
var json = raw is string s ? s : System.Text.Json.JsonSerializer.Serialize(raw);
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty(userType, out var arr))
{
return false;
}
return arr.EnumerateArray()
.Any(x => string.Equals(x.GetString(), provider, System.StringComparison.OrdinalIgnoreCase));
}
}
Notes:
OnPostExternalLogin is a plain virtual method, so calling it from OnGetAsync works.UserType is added to AuthenticationProperties.Items. ASP.NET Core protects this state when the OIDC handler builds the authorize URL, and it comes back via the external login info on callback — so the value is tamper-resistant for the OAuth round-trip.ExtraProperties requires ITenantRepository, not ITenantStore — TenantConfiguration does not expose ExtraProperties. The Tenant entity lives in the host scope, so the repository call is wrapped in CurrentTenant.Change(null) to bypass the tenant data filter (same pattern ABP's own TenantStore uses internally). In production, cache the per-tenant whitelist (e.g. via IDistributedCache or IMemoryCache) to avoid a DB hit on every login.UserType represents a fixed property of the user (e.g. Internal = employee, External = customer), also validate after local login (OnPostAsync) that the signed-in user's stored UserType matches the URL's userType. That code is short but very tied to your domain, so it's not shown here./{tenant}/{userType}/{provider} → /Account/LoginThe route-based tenant resolution side is covered in Resolving Tenant from Route in ABP Framework. After that middleware runs, /tenant1 is moved into PathBase, so the remaining path is /internal/entraid. Add one more small middleware to forward that into the standard /Account/Login page:
// Place after the tenant PathBase middleware and before app.UseRouting().
app.Use(async (httpContext, next) =>
{
var path = httpContext.Request.Path;
string userType = null;
PathString rest = default;
if (path.StartsWithSegments("/internal", out rest))
{
userType = "internal";
}
else if (path.StartsWithSegments("/external", out rest))
{
userType = "external";
}
// Accept only one trailing segment (the provider name) — reject e.g. /internal/a/b.
if (userType != null)
{
var provider = rest.HasValue ? rest.Value!.Trim('/') : null;
if (string.IsNullOrEmpty(provider) || provider.Contains('/'))
{
await next(httpContext);
return;
}
var query = QueryString.Create(new[]
{
new KeyValuePair<string, string>("userType", userType),
new KeyValuePair<string, string>("provider", provider),
});
httpContext.Request.Path = "/Account/Login";
httpContext.Request.QueryString = httpContext.Request.QueryString.Add(query);
}
await next(httpContext);
});
/tenant1/internal/entraid then resolves as: PathBase=/tenant1, Path=/Account/Login, Query=userType=internal&provider=entraid. Hitting that URL goes straight into the IdP redirect — the login page never renders.
Don't use OpenIddict applications for this — those are for the client apps that authenticate against your AuthServer, not for external login providers.
Two separate storage concerns:
| Data | Where to store |
| --- | --- |
| ClientId / ClientSecret / Authority per tenant per provider | Account Pro's External Provider Settings (already multi-tenant; see my previous reply) |
| The list of providers allowed for (tenant, userType) | Tenant ExtraProperties as JSON, or a small custom table if you prefer relational storage |
Tenant ExtraProperties is convenient for the whitelist. Example payload:
{
"AllowedProviders": {
"internal": ["AzureOpenId", "MyID"],
"external": ["local", "Facebook"]
}
}
Read it via ITenantRepository.FindAsync(tenantId) inside IsProviderAllowedForCurrentTenantAsync. Don't use ITenantStore — the cached TenantConfiguration it returns does not expose ExtraProperties. Add your own caching layer if read frequency is a concern.
This is the WithDynamicOptions<TOptions, THandler> block from my previous reply. For MyID, register it the same way as Azure AD if it speaks OIDC. The OIDC handler resolves discovery on every Authority change, so you must also register an IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions> post-configurator alongside the dynamic options:
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Volo.Abp.Account.ExternalProviders;
using Volo.Abp.Account.Public.Web.ExternalProviders;
// inside ConfigureServices
context.Services.AddAuthentication()
.AddOpenIdConnect("MyID", "MyID", options => { /* static defaults */ })
.WithDynamicOptions<OpenIdConnectOptions, OpenIdConnectHandler>("MyID",
o =>
{
o.WithProperty(x => x.Authority);
o.WithProperty(x => x.ClientId);
o.WithProperty(x => x.ClientSecret, isSecret: true);
});
context.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<
IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions>,
OpenIdConnectPostConfigureAccountExternalProviderOptions>());
public class OpenIdConnectPostConfigureAccountExternalProviderOptions
: IPostConfigureAccountExternalProviderOptions<OpenIdConnectOptions>
{
private readonly IEnumerable<IPostConfigureOptions<OpenIdConnectOptions>> _postConfigureOptions;
public OpenIdConnectPostConfigureAccountExternalProviderOptions(
IEnumerable<IPostConfigureOptions<OpenIdConnectOptions>> postConfigureOptions)
{
_postConfigureOptions = postConfigureOptions;
}
public Task PostConfigureAsync(string name, OpenIdConnectOptions options)
{
foreach (var configureOption in _postConfigureOptions)
{
configureOption.PostConfigure(name, options);
}
return Task.CompletedTask;
}
}
If MyID is not OIDC (SAML, custom protocol, etc.), let me know what it is and I'll send the right registration shape.
There's no ready-made ABP sample combining all of this, but the closest official pieces are:
OpenIddictSupportedLoginModel already present in your AuthServer project — the override above follows the same pattern.Thanks
Hi,
This is a bug in the OpenIddict password grant under Shared User Accounts. At /connect/token the user is looked up in the Host context with the plain lookup, which only matches host accounts (TenantId = null). The row you see for userA in the host DB has the Dev TenantId — it's a tenant-association record created by accept-invite, not a host account, and userA has no host-level account at all. So the host-context lookup filters that row out and the token endpoint reports "no user found" → invalid_grant. The MVC/Blazor login already uses the shared-account-aware lookup, which is why the same user can sign in there.
It's not caused by database-per-tenant or your custom tenant header — under Shared accounts the login is Host-side by design, so the tenant header at /connect/token is expected to be ignored.
As a workaround until the fix ships, replace the token controller in your AuthServer/API host project so the password grant uses the shared lookup:
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using Volo.Abp.AspNetCore.Controllers;
using Volo.Abp.MultiTenancy;
using Volo.Abp.OpenIddict.Controllers;
using Volo.Abp.Uow;
namespace MyCompanyName.MyProjectName;
[ReplaceControllers(typeof(TokenController))]
public class SharedAwareTokenController : TokenController
{
[UnitOfWork]
protected override async Task<IActionResult> HandlePasswordAsync(OpenIddictRequest request)
{
var tenant = await TenantConfigurationProvider.GetAsync(saveResolveResult: false);
using (CurrentTenant.Change(tenant?.Id))
{
await IdentityOptions.SetAsync();
// Resolve the user across tenants from the Host context.
var user = await UserManager.FindSharedUserByNameAsync(request.Username)
?? await UserManager.FindSharedUserByEmailAsync(request.Username);
if (user == null)
{
// Unknown user: let the base controller return the standard invalid_grant.
return await base.HandlePasswordAsync(request);
}
using (CurrentTenant.Change(user.TenantId))
{
await IdentityOptions.SetAsync();
var result = await SignInManager.CheckPasswordSignInAsync(user, request.Password, true);
if (!result.Succeeded)
{
return Forbid(
new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid username or password!"
}),
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (await IsTfaEnabledAsync(user))
{
return await HandleTwoFactorLoginAsync(request, user);
}
return await SetSuccessResultAsync(request, user);
}
}
}
}
With this in place, userA logs in at /connect/token with just username/email + password (no tenant header needed), and the issued token is already scoped to the tenant the user belongs to.
This is a minimal override focused on the lookup — it returns a generic invalid_grant on a failed sign-in. We'll fix this in the framework itself in an upcoming release (keeping the detailed lockout/inactive/change-password handling), so the override won't be needed.
Thanks
Hi,
LanguageText (the AbpLanguageTexts entity) is IMultiTenant, so when a tenant resolves a localized string, ABP applies the standard WHERE TenantId = CurrentTenantId filter. That filter doesn't fall back to host records — your TenantId = null rows are simply filtered out for tenant requests. The fallback described in the docs is about the embedded JSON resources (e.g. a missing vi.json falling back to en.json), not a cross-tenant fallback on the DB side.
To make the host-level texts apply to all tenants, override DynamicResourceLocalizer and switch to the host context when loading the cache item:
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;
using Volo.Abp.LanguageManagement;
using Volo.Abp.Localization;
using Volo.Abp.MultiTenancy;
namespace MyCompanyName.MyProjectName;
[Dependency(ReplaceServices = true)]
[ExposeServices(typeof(DynamicResourceLocalizer), typeof(IDynamicResourceLocalizer))]
public class MyDynamicResourceLocalizer : DynamicResourceLocalizer
{
public MyDynamicResourceLocalizer(
IServiceScopeFactory serviceScopeFactory,
IDistributedCache<LanguageTextCacheItem> cache)
: base(serviceScopeFactory, cache)
{
}
protected override LanguageTextCacheItem CreateCacheItem(LocalizationResourceBase resource, string cultureName)
{
var cacheItem = new LanguageTextCacheItem();
using (var scope = ServiceScopeFactory.CreateScope())
{
var currentTenant = scope.ServiceProvider.GetRequiredService<ICurrentTenant>();
using (currentTenant.Change(null))
{
var texts = scope.ServiceProvider
.GetRequiredService<ILanguageTextRepository>()
.GetList(resource.ResourceName, cultureName);
foreach (var text in texts)
{
cacheItem.Dictionary[text.Name] = text.Value;
}
}
}
return cacheItem;
}
protected override async Task<LanguageTextCacheItem> CreateCacheItemAsync(LocalizationResourceBase resource, string cultureName)
{
var cacheItem = new LanguageTextCacheItem();
using (var scope = ServiceScopeFactory.CreateScope())
{
var currentTenant = scope.ServiceProvider.GetRequiredService<ICurrentTenant>();
using (currentTenant.Change(null))
{
var texts = await scope.ServiceProvider
.GetRequiredService<ILanguageTextRepository>()
.GetListAsync(resource.ResourceName, cultureName);
foreach (var text in texts)
{
cacheItem.Dictionary[text.Name] = text.Value;
}
}
}
return cacheItem;
}
}
The [Dependency(ReplaceServices = true)] + [ExposeServices(...)] attributes are enough — no extra AddSingleton registration is needed. After restarting the app, every tenant (including newly created ones) will read the Vietnamese translations from your TenantId = null rows.
Same approach we suggested earlier in https://abp.io/support/questions/1642 if you want more background.
Thanks
Hi,
Thanks for digging into the first one. Quick note though: GetTransactionPermissionPredicateAsync is a method you added yourself (Suite never generates Func<...> return types), and it's only being picked up by ABP's auto API controllers because it's public on an AppService. The cleaner fix is to change it from public to protected — ABP only exposes public methods as endpoints, so once it's protected it won't be auto-mapped, won't end up in generate-proxy.json, and the schematics step won't try to map that Func<...> return type. [RemoteService(IsEnabled = false)] works too, but it's a workaround that leaves the method visible as part of the public AppService surface.
If you actually want this method to be exposed as an application service / HTTP API, you'll need to redesign it: a Func<...> delegate can't be serialized over HTTP, so there's no way for a client to receive or invoke it. Return a plain DTO instead (the IDs, flags, or evaluated result the predicate would compute) and let the caller use that.
Clearing the stale entry from generate-proxy.json was needed because that file works as a lock file (the README the schematics generates next to it says the same) and ABP doesn't remove entries from it when a method is excluded later. We'll also track an improvement so the Suite progress dialog surfaces the actual schematics error instead of showing the step as "completed" when the subprocess fails.
For the new %%is-nullable%% / %%null-check%% issue — that's a real bug in Suite 9.3.5 (and earlier 9.3.x). The EntityGenerateCommand fills in the navigation-property partial without substituting those two placeholders, so they get written straight into <Entity>WithNavigationProperties.cs and the project no longer compiles, which is also why the migration step fails right after.
This is already fixed in ABP 10.0.0 (the fix wasn't back-ported to 9.3.x). Upgrading to the latest 10.x release will resolve it.
If upgrading isn't an option right now, you can manually edit the generated <Entity>WithNavigationProperties.cs after Suite finishes:
%%is-nullable%% with ? for optional navigations, or remove it for required ones.%%null-check%% with = null!; for required navigations, or remove it for optional ones.Then the project will compile and the migration step will go through.
Thanks
: )