Activities of "AI-Bot"

  1. Solution The behavior you see almost always comes from how ABP’s Http API pipeline applies:
  • Authorization
  • Anti-forgery
  • Conventional vs attribute routing
  • Frontend helper headers (X-Requested-With, Abp-Tenant, etc.)

Swagger succeeds because it automatically sends the required headers (Authorization, X-Requested-With, RequestVerificationToken, etc.). Your browser/web client likely does not. That often results in a 404 Not Found (ABP hides some auth-related responses to avoid information leakage) unless you send the expected headers/tokens.

Checklist to fix the Not Found when calling from Web:

  • Ensure you send Authorization like Swagger does

    • Send a valid Bearer token: Authorization: Bearer <access_token>
  • Include the ABP Ajax header to avoid anti-forgery validation (GET is safe, but ABP expects the Ajax header in many UI scenarios)

    • X-Requested-With: XMLHttpRequest
  • If you enabled Anti-Forgery (default for MVC/Razor), either:

    • Send the RequestVerificationToken header (value from the hidden input/__RequestVerificationToken cookie) like Swagger does, or
    • Mark the endpoint to ignore antiforgery: [IgnoreAntiforgeryToken] [HttpGet("lookup/autocomplete")] public virtual Task<ListResultDto<VehicleAutocompleteDto>> AutocompleteAsync([FromQuery] string keyword) { ... }
  • Confirm your frontend is calling the exact same URL and method

    • GET http://localhost:44303/api/ksvr/vehicle-owners/lookup/autocomplete?keyword=29
    • If you have a reverse proxy/base path, ensure no extra prefix is added.
  • If your app is multi-tenant and the request needs tenant context

    • Send Abp-TenantId or Abp-Tenant (tenant name) header if required by your app.
    • Example: Abp-TenantId: <guid> or Abp-Tenant: <tenant-name>
  • Ensure route precedence is not overridden

    • You already placed [HttpGet("lookup/autocomplete")] before [HttpGet("{id:int}")], which is correct. If you have any global conventions modifying routes (like adding {__tenant}), confirm the final route matches what you call.
  • Test quickly with curl or Postman using the same headers Swagger shows

    • From Swagger “curl” box, copy all headers used there and try the same request outside Swagger. If it works, then add those headers to your web client.

Example: minimal safe controller annotations if you don’t want anti-forgery for GET

[Authorize(KSVRPermissions.VehicleOwners.Default)]
  [Route("api/ksvr/vehicle-owners")]
  public class VehicleOwnersAppService : ApplicationService
  {
      [IgnoreAntiforgeryToken] // optional if you face CSRF checks in your UI pipeline
      [HttpGet("lookup/autocomplete")]
      public virtual async Task<ListResultDto<VehicleAutocompleteDto>> AutocompleteAsync([FromQuery] string keyword)
      {
          ...
      }
  }

And sample JS fetch with the required headers:

const res = await fetch('http://localhost:44303/api/ksvr/vehicle-owners/lookup/autocomplete?keyword=29', {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'X-Requested-With': 'XMLHttpRequest'
      // 'RequestVerificationToken': csrfToken // if needed in your setup
      // 'Abp-Tenant': tenantName // if multi-tenant by name
    },
    credentials: 'include'
  });

Why your other API works while autocomplete doesn’t

  • The “by id” route might be hit from a tool that automatically includes Authorization (or your web call includes the token there), or your browser cached a token/cookie. The lookup route likely misses one of the headers above, causing ABP to return 404.
  1. Sources
  • https://docs.abp.io/en/abp/latest/API/Auto-API-Controllers

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution
  • Problem recap: Each tenant has a completely different domain (no common base), and each tenant must use its own OIDC ClientId and its own authority/redirect/logout URLs. You’re on ABP 10.0.0, Blazor Web App, Auth server embedded in your API. You also don’t use the tenant cookie resolver.

  • Key points supported by ABP:

    • Domain tenant resolution supports full-domain patterns (whole-domain mapping), not only subdomains. You can add multiple domain formats or implement a custom tenant resolver to map arbitrary domains to tenants.
    • Per-tenant OIDC clients are first-class in OpenIddict module: you can persist multiple applications (clients) and select them per-tenant.
    • For server-rendered Blazor (OpenIdConnect handler), you can override the IssuerAddress on redirects; for WASM, set ProviderOptions.Authority at runtime based on current host.
    • Wildcard domain helpers are for subdomain patterns; since your domains are all different and not sharing a placeholder, do not use wildcard-domain feature. Seed exact redirect/cors origins for each tenant.
  • Step-by-step

A) Resolve tenant by arbitrary domain (no shared base):

  • Use DomainTenantResolver with multiple patterns when possible (hostnames that can be expressed as formats). For fully custom domains that don’t fit a single pattern, add a custom ITenantResolveContributor to map hostnames to tenants.
using Volo.Abp.MultiTenancy;
using Microsoft.AspNetCore.Http;

public class CustomDomainTenantResolveContributor : TenantResolveContributorBase
{
    public const string ContributorName = "CustomDomain";
    public override string Name => ContributorName;

    protected override Task<string?> GetTenantIdOrNameFromHttpContextOrNullAsync(
        ITenantResolveContext context, HttpContext httpContext)
    {
        var host = httpContext.Request.Host.Host.ToLowerInvariant();

        // Map arbitrary domains to tenant names
        // Example mappings; move to DB/config as needed
        return Task.FromResult(host switch
        {
            "contoso-portal.com" => "contoso",
            "fabrikam-portal.net" => "fabrikam",
            "tailspinsolutions.io" => "tailspin",
            _ => null
        });
    }
}

public class YourHttpApiHostModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        Configure<AbpTenantResolveOptions>(opts =>
        {
            // Optional: keep any domain formats you can express
            // opts.AddDomainTenantResolver("{0}.example.com");

            // Add custom mapping contributor at the beginning
            opts.TenantResolvers.Insert(0, new CustomDomainTenantResolveContributor());
        });
    }
}
  • Reference: ABP’s tenant resolvers and domain resolver (you can add your own contributor to handle full-domain mappings).

B) Store and use per-tenant OIDC client settings (ClientId, redirect/logout URLs):

  • Seed one OpenIddict application (client) per tenant with its exact redirect and logout URIs and allowed CORS origins. Do not rely on wildcard domain feature here, since your tenants don’t share a domain pattern.
  • You can seed OpenIddict applications in a data seeder that runs per tenant:
public class MyOpenIddictSeeder : ITransientDependency
{
    private readonly ICurrentTenant _currentTenant;
    private readonly IOpenIddictApplicationManager _appManager;

    public MyOpenIddictSeeder(ICurrentTenant currentTenant, IOpenIddictApplicationManager appManager)
    {
        _currentTenant = currentTenant;
        _appManager = appManager;
    }

    public async Task SeedAsync()
    {
        using (_currentTenant.Change("contoso")) // repeat for each tenant, or loop from a store
        {
            var clientId = "contoso_blazor";
            var redirectUri = "https://contoso-portal.com/signin-oidc";
            var postLogoutRedirectUri = "https://contoso-portal.com/signout-callback-oidc";
            var corsOrigin = "https://contoso-portal.com";

            if (await _appManager.FindByClientIdAsync(clientId) == null)
            {
                await _appManager.CreateAsync(new OpenIddictApplicationDescriptor
                {
                    ClientId = clientId,
                    DisplayName = "Contoso Blazor",
                    Type = OpenIddictConstants.ClientTypes.Public,
                    ConsentType = OpenIddictConstants.ConsentTypes.Implicit,
                    RedirectUris = { new Uri(redirectUri) },
                    PostLogoutRedirectUris = { new Uri(postLogoutRedirectUri) },
                    Permissions =
                    {
                        OpenIddictConstants.Permissions.Endpoints.Authorization,
                        OpenIddictConstants.Permissions.Endpoints.Token,
                        OpenIddictConstants.Permissions.Endpoints.Logout,
                        OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
                        OpenIddictConstants.Permissions.ResponseTypes.Code,
                        OpenIddictConstants.Permissions.Scopes.Email,
                        OpenIddictConstants.Permissions.Scopes.Profile,
                        OpenIddictConstants.Permissions.Scopes.Roles
                    },
                    Requirements = { OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange }
                });

                // If you use ABP helper validators for client config, also register CORS origins appropriately.
            }
        }

        // Repeat for each tenant with its own exact domains/URIs.
    }
}
  • Important: Each tenant gets its own ClientId (e.g., contoso_blazor, fabrikam_blazor), and each uses its own exact redirect/logout URIs. No wildcard.

C) Make the Blazor Web App use the tenant’s client and authority dynamically

  • For Blazor Server/Web (OpenIdConnect handler), select authority and client based on the resolved tenant in redirect events, and select the ClientId per tenant. Store a mapping in a configuration or data store.
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Volo.Abp.MultiTenancy;

public class TenantOidcOptionsConfigurator : IConfigureNamedOptions<OpenIdConnectOptions>
{
    private readonly IConfiguration _cfg;
    private readonly ICurrentTenant _currentTenant;

    public TenantOidcOptionsConfigurator(IConfiguration cfg, ICurrentTenant currentTenant)
    {
        _cfg = cfg;
        _currentTenant = currentTenant;
    }

    public void Configure(string name, OpenIdConnectOptions options)
    {
        if (name != "oidc") return;

        var map = _cfg.GetSection("TenantOidc").Get<Dictionary<string, TenantOidcConfig>>();
        // TenantOidc: { "contoso": { "Authority": "https://api.contoso.com", "ClientId": "contoso_blazor" }, ... }

        var tenantName = _currentTenant.Name;
        if (!tenantName.IsNullOrWhiteSpace() && map != null && map.TryGetValue(tenantName!, out var t))
        {
            options.Authority = t.Authority; // must match issuer
            options.ClientId = t.ClientId;

            var prevLogin = options.Events.OnRedirectToIdentityProvider;
            options.Events.OnRedirectToIdentityProvider = async ctx =>
            {
                ctx.ProtocolMessage.IssuerAddress = options.Authority!.EnsureEndsWith('/') + "connect/authorize";
                if (prevLogin != null) await prevLogin(ctx);
            };

            var prevLogout = options.Events.OnRedirectToIdentityProviderForSignOut;
            options.Events.OnRedirectToIdentityProviderForSignOut = async ctx =>
            {
                ctx.ProtocolMessage.IssuerAddress = options.Authority!.EnsureEndsWith('/') + "connect/logout";
                if (prevLogout != null) await prevLogout(ctx);
            };
        }
    }

    public void Configure(OpenIdConnectOptions options) => Configure(Options.DefaultName, options);
}

public class TenantOidcConfig
{
    public string Authority { get; set; }
    public string ClientId { get; set; }
}
  • Register:
context.Services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, TenantOidcOptionsConfigurator>();
  • appsettings.json example:
{
  "TenantOidc": {
    "contoso": {
      "Authority": "https://api.contoso.com",
      "ClientId": "contoso_blazor"
    },
    "fabrikam": {
      "Authority": "https://api.fabrikam.net",
      "ClientId": "fabrikam_blazor"
    }
  }
}
  • For Blazor WASM: compute Authority and ClientId at runtime from the current host. Keep a dictionary in appsettings and select by the domain (or tenant name you resolved client-side).
builder.Services.AddOidcAuthentication(options =>
{
    var cfg = builder.Configuration.GetSection("TenantOidc").GetChildren()
        .ToDictionary(x => x.Key, x => new { Authority = x["Authority"], ClientId = x["ClientId"] });

    var host = new Uri(builder.HostEnvironment.BaseAddress).Host.ToLowerInvariant();

    // Map host -> tenant key, you can keep a map section HostToTenant in config
    var hostMap = builder.Configuration.GetSection("HostToTenant").Get<Dictionary<string,string>>();
    if (hostMap != null && hostMap.TryGetValue(host, out var tenantKey) && cfg.TryGetValue(tenantKey, out var t))
    {
        options.ProviderOptions.Authority = t.Authority;
        options.ProviderOptions.ClientId = t.ClientId;
    }

    options.ProviderOptions.ResponseType = "code";
});
  • appsettings for WASM:
{
  "TenantOidc": {
    "contoso": { "Authority": "https://api.contoso.com", "ClientId": "contoso_wasm" },
    "fabrikam": { "Authority": "https://api.fabrikam.net", "ClientId": "fabrikam_wasm" }
  },
  "HostToTenant": {
    "contoso-portal.com": "contoso",
    "fabrikam-portal.net": "fabrikam"
  }
}

D) Token validation in API when multiple issuers exist

  • Since each tenant can have a different Authority/Issuer, configure JWT bearer validation to accept multiple issuers or implement a custom IssuerValidator. If all tenants share the same embedded OpenIddict server with a single issuer, keep a single Authority. If issuers differ by tenant, set ValidIssuers accordingly or implement a custom validator.
context.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.RequireHttpsMetadata = true;

    // If each tenant has a different issuer:
    options.TokenValidationParameters.ValidIssuers = new[]
    {
        "https://api.contoso.com/",
        "https://api.fabrikam.net/"
    };

    // Or implement a custom IssuerValidator to check against a store of tenant issuers.
});

E) Disable tenant switching UI on AuthServer

  • When tenant must be forced by domain, don’t expose tenant switching. With domain or custom resolver in place, the current tenant is already determined from the request host, and you can hide the switcher in the Account UI. ABP’s guidance: use domain-based resolver and do not rely on cookie/header switchers.

F) Summary checklist for your scenario (unique domains)

  • Implement a CustomDomainTenantResolveContributor to map arbitrary domains to tenants.
  • Seed one OpenIddict application per tenant with exact redirect/logout URIs and CORS origins. Use per-tenant ClientId.
  • In Blazor Server: configure OpenIdConnectOptions per request using ICurrentTenant to choose Authority and ClientId before redirecting.
  • In Blazor WASM: select Authority and ClientId at startup based on current host.
  • Configure JWT validation to support multiple issuers if necessary.
  • Do not enable wildcard domain feature for OpenIddict in this case, since domains are unrelated.
  • Ensure each tenant’s domain is registered in AllowedCorsOrigins and RedirectUris exactly.
  1. Sources
  • Multi-tenancy tenant resolution (domain/route and custom contributors):
    • https://docs.abp.io/en/abp/latest/Multi-Tenancy#tenant-resolution
    • https://abp.io/docs/latest/framework/architecture/multi-tenancy
  • OpenIddict module and deployment in ABP:
    • https://abp.io/docs/10.0//modules/openiddict
    • https://abp.io/docs/10.0//solution-templates/layered-web-application/deployment/openiddict-deployment
  • Server-side apps intercepting OIDC redirect to enforce proper Authority (pattern to adapt for per-tenant):
    • https://abp.io/docs/10.0//guides/ms-multi-tenant-domain-resolving

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution
  • Goal: Resolve tenant by domain and use per-tenant OpenId Connect settings (Authority, ClientId, redirect/logout URIs) in a single instance with unique domains per tenant, Blazor Web App UI, OpenIddict embedded in HttpApi.Host.

  • Backend (HttpApi.Host) – enable domain-based tenant resolution and wildcard redirect/cors support:

using Volo.Abp.MultiTenancy;
  using Volo.Abp.OpenIddict.WildcardDomains;

  public override void ConfigureServices(ServiceConfigurationContext context)
  {
      var configuration = context.Services.GetConfiguration();

      // Resolve tenant by domain/subdomain
      Configure<AbpTenantResolveOptions>(options =>
      {
          // Example: {0}.yourdomain.com -> tenant name from subdomain
          options.AddDomainTenantResolver(configuration["TenantDomain"]); // e.g. "https://{0}.api.yourdomain.com"
      });

      // OpenIddict wildcard domain support for redirect & post-logout redirect URIs
      PreConfigure<AbpOpenIddictWildcardDomainOptions>(options =>
      {
          options.EnableWildcardDomainSupport = true;
          // Add all app URL formats that will appear as redirect_uri/post_logout_redirect_uri
          // Examples:
          options.WildcardDomainsFormat.Add(configuration["WildCardDomains:Blazor"]);      // "https://{0}.yourdomain.com"
          options.WildcardDomainsFormat.Add(configuration["WildCardDomains:Api"]);         // "https://{0}.api.yourdomain.com"
      });
  }

appsettings.json (examples):

{
    "TenantDomain": "https://{0}.yourdomain.com", // or "https://{0}.api.yourdomain.com" depending on which host resolves tenants
    "WildCardDomains": {
      "Blazor": "https://{0}.yourdomain.com",
      "Api": "https://{0}.api.yourdomain.com"
    },
    "AuthServer": {
      "Authority": "https://{0}.api.yourdomain.com", // embedded OpenIddict issuer format
      "RequireHttpsMetadata": "true"
    }
  }
  • OpenIddict issuer and discovery handling (Blazor Server/Web/MVC if applicable): If you need to force login/logout to tenant-specific endpoints, adjust OIDC events to rewrite the IssuerAddress based on ICurrentTenant:
context.Services.Configure<OpenIdConnectOptions>("oidc", options =>
  {
      var prevLogin = options.Events.OnRedirectToIdentityProvider;
      options.Events.OnRedirectToIdentityProvider = async ctx =>
      {
          var cfg = ctx.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
          var currentTenant = ctx.HttpContext.RequestServices.GetRequiredService<ICurrentTenant>();
          var baseAuthority = cfg["AuthServer:Authority"]!.EnsureEndsWith('/') + "connect/authorize";
          ctx.ProtocolMessage.IssuerAddress = baseAuthority;

          var tenantDomainFormat = cfg["TenantDomain"];
          if (currentTenant.IsAvailable && !string.IsNullOrEmpty(tenantDomainFormat))
          {
              ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}", currentTenant.Name);
          }
          else
          {
              ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}.", string.Empty);
          }

          if (prevLogin != null) await prevLogin(ctx);
      };

      var prevLogout = options.Events.OnRedirectToIdentityProviderForSignOut;
      options.Events.OnRedirectToIdentityProviderForSignOut = async ctx =>
      {
          var cfg = ctx.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
          var currentTenant = ctx.HttpContext.RequestServices.GetRequiredService<ICurrentTenant>();
          var baseLogout = cfg["AuthServer:Authority"]!.EnsureEndsWith('/') + "connect/logout";
          ctx.ProtocolMessage.IssuerAddress = baseLogout;

          var tenantDomainFormat = cfg["TenantDomain"];
          if (currentTenant.IsAvailable && !string.IsNullOrEmpty(tenantDomainFormat))
          {
              ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}", currentTenant.Name);
          }
          else
          {
              ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}.", string.Empty);
          }

          if (prevLogout != null) await prevLogout(ctx);
      };
  });

Note: For Blazor WebAssembly, configure the client at startup (below).

  • Blazor Web App (WASM) – set Authority/BaseUrl dynamically per tenant from current domain: In the Blazor WASM project module (Program.cs or module class), compute URLs by replacing {0} with the tenant segment parsed from window.location.
private static string[] Protocols = { "http://", "https://" };

  private static string GetTenantSegment(string baseUrl)
  {
      var host = baseUrl;
      foreach (var p in Protocols) if (host.StartsWith(p, StringComparison.OrdinalIgnoreCase)) host = host.Substring(p.Length);
      var parts = host.Split('.');
      // If pattern is tenant.domain.tld -> odd length implies first part is tenant
      return parts.Length % 2 == 1 ? parts[0] : null;
  }

  private static string ReplaceTenant(string patternUrl, string currentBase)
  {
      var tenant = GetTenantSegment(currentBase);
      return tenant != null ? patternUrl.Replace("{0}", tenant) : patternUrl.Replace("{0}.", string.Empty);
  }

  private static void ConfigureAuthentication(WebAssemblyHostBuilder builder)
  {
      builder.Services.AddOidcAuthentication(options =>
      {
          builder.Configuration.Bind("AuthServer", options.ProviderOptions);
          // Override Authority with tenant-specific domain
          var currentBase = builder.HostEnvironment.BaseAddress;
          var configuredAuthority = builder.Configuration["AuthServer:Authority"]; // e.g. "https://{0}.api.yourdomain.com"
          options.ProviderOptions.Authority = ReplaceTenant(configuredAuthority, currentBase);
      });
  }

  private static void ConfigureRemoteServices(WebAssemblyHostBuilder builder)
  {
      Configure<AbpRemoteServiceOptions>(opts =>
      {
          var baseConfigured = builder.Configuration["RemoteServices:Default:BaseUrl"]; // e.g. "https://{0}.api.yourdomain.com"
          opts.RemoteServices.Default = new RemoteServiceConfiguration(ReplaceTenant(baseConfigured, builder.HostEnvironment.BaseAddress));
      });
  }

appsettings.json (Blazor WASM):

{
    "App": { "SelfUrl": "https://{0}.yourdomain.com" },
    "AuthServer": {
      "Authority": "https://{0}.api.yourdomain.com",
      "ClientId": "YourClient_Wasm",
      "ResponseType": "code"
    },
    "RemoteServices": {
      "Default": { "BaseUrl": "https://{0}.api.yourdomain.com" }
    }
  }
  • OpenIddict client seeding – allow wildcard redirect/logout URIs: With AbpOpenIddictWildcardDomainOptions enabled on the AuthServer/Host, you don’t need to seed every tenant domain. Seed clients with RootUrl formats or just enable wildcard formats via options as shown above. This prevents “invalid redirect_uri” for per-tenant domains.

  • If you validate access tokens on separate hosts with wildcard issuer: When tokens are validated by an API that uses a format Authority (https://{0}.api.yourdomain.com), configure issuer validation to accept the wildcard issuer. If you’re not using the same web host for validation or need strict issuer validation with a single scheme, use Owl.TokenWildcardIssuerValidator:

// using Owl.TokenWildcardIssuerValidator;
  context.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
      .AddJwtBearer(options =>
      {
          options.Authority = configuration["AuthServer:Authority"]; // "https://{0}.api.yourdomain.com"
          options.RequireHttpsMetadata = true;
          options.TokenValidationParameters.IssuerValidator = TokenWildcardIssuerValidator.IssuerValidator;
          options.TokenValidationParameters.ValidIssuers = new[]
          {
              "https://{0}.api.yourdomain.com/"
          };
      });
  • Summary checklist:
    • Configure AbpTenantResolveOptions.AddDomainTenantResolver with your tenant domain format.
    • Enable AbpOpenIddictWildcardDomainOptions and add all wildcard formats used by clients (Blazor app, API, etc.).
    • In Blazor WASM, compute Authority/BaseUrl at runtime based on current host, replacing {0}.
    • If validating tokens with wildcard issuer, add wildcard issuer validator (when needed).
    • Do not use “*.domain.com” in redirect URIs; use ABP OpenIddict wildcard support as above.
  1. Sources
  • https://docs.abp.io/en/abp/latest/Multi-Tenancy#tenant-resolution
  • https://abp.io/docs/10.0//guides/ms-multi-tenant-domain-resolving
  • https://abp.io/docs/10.0//framework/architecture/multi-tenancy/

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution
  • Root cause
    • In ABP v10 the default object mapper is Mapperly. Unlike AutoMapper, when a collection source is null, Mapperly keeps the destination collection null (it does not convert it to an empty list). Suite-generated mappers for “WithNavigationProperties” DTOs call a generated method similar to MapToListOfChildDto(source). If EF returns null for a child collection (e.g., Card.ChildVariants or the navigation-joined list inside a WithNavigationProperties projection), Mapperly’s generated foreach over the source collection throws a NullReferenceException.
  • Fix options
    1. Ensure collection properties are never null in your entities and DTOs:
      • Initialize child collections in the aggregate entity constructors.
public class Card : FullAuditedAggregateRoot<Guid>
       {
           public Guid? BankId { get; set; }
           public virtual Bank? Bank { get; set; }
           public virtual ICollection<CardVariant> Variants { get; set; }

           public Card()
           {
               Variants = new List<CardVariant>(); // important
           }
       }

       public class CardWithNavigationProperties
       {
           public Card Card { get; set; } = default!;
           public Bank? Bank { get; set; }
           public List<CardVariant> Variants { get; set; } = new(); // important
       }
 - Do the same for generated DTOs if they have child collection properties:
public class CardWithNavigationPropertiesDto
       {
           public CardDto Card { get; set; } = default!;
           public BankDto? Bank { get; set; }
           public List<CardVariantDto> Variants { get; set; } = new(); // important
       }
  1. Null-coalesce before mapping list properties in query projections to ensure the mapper receives an empty list instead of null:
var query = from card in _cardRepository
                 join bank in _bankRepository on card.BankId equals bank.Id into bankJoin
                 from bank in bankJoin.DefaultIfEmpty()
                 select new CardWithNavigationProperties
                 {
                     Card = card,
                     Bank = bank,
                     Variants = card.Variants ?? new List<CardVariant>() // important
                 };
  1. Add a small custom mapper to guard against null lists:
[Mapper]
     public partial class CardWithNavigationPropertiesMapper
         : IAbpMapperlyMapper<CardWithNavigationProperties, CardWithNavigationPropertiesDto>, ITransientDependency
     {
         [MapperIgnoreSource(nameof(CardWithNavigationProperties.Variants))]
         public partial CardWithNavigationPropertiesDto Map(CardWithNavigationProperties source);

         public partial void Map(CardWithNavigationProperties source, CardWithNavigationPropertiesDto destination);

         public void AfterMap(CardWithNavigationProperties source, CardWithNavigationPropertiesDto destination)
         {
             // Safe map of collection
             var items = source.Variants ?? new List<CardVariant>();
             destination.Variants = items.Count == 0
                 ? new List<CardVariantDto>()
                 : items.Select(MapCardVariant).ToList();
         }

         private partial CardVariantDto MapCardVariant(CardVariant source);
     }
 This ensures the generated foreach never receives a null collection.
  1. If you prefer AutoMapper’s old behavior (null -> empty collection) for specific pairs, implement IObjectMapper<TSource, TDestination> for those pairs and normalize nulls inside your Map methods.
  • Why it happens now

    • This is an intentional behavior difference of Mapperly vs AutoMapper for collections. Suite’s generated code relies on the mapper to iterate child collections; when EF or navigation shape returns null, the generated foreach throws.
  • Quick checklist

    • Initialize all collection properties in:
      • Entities
      • WithNavigationProperties wrapper types
      • DTOs
    • In LINQ projections, coalesce collections: x.Children ?? new List<Child>()
    • For advanced cases, add a custom Mapperly mapper or IObjectMapper to normalize nulls.
  1. Sources
  • Validated links only:
    • https://github.com/abpframework/abp
    • Note: Mapperly list/null behavior referenced from ABP object-to-object mapping docs: “When mapping a collection property, if the source value is null Mapperly will keep the destination value as null. This is different from AutoMapper, which will map the destination field to an empty collection.” (ABP docs for v10 object-to-object mapping)

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution
  • Show password errors on separate lines (Auth Server MVC)
    • Create a custom Account page model by overriding AccountPageModel.GetLocalizedExceptionMessage to split Identity password errors and join them with newline characters.
    • Sample implementation:
using System.Linq;
  using System.Text;
  using Microsoft.AspNetCore.Identity;
  using Volo.Abp.Account.Web.Pages.Account;
  using Volo.Abp;
  using Volo.Abp.ExceptionHandling;

  namespace MyCompany.MyProject.Web.Pages.Account
  {
      // Works for Login/Register/Manage pages that inherit AccountPageModel
      public class MyAccountPageModel : AccountPageModel
      {
          protected override string GetLocalizedExceptionMessage(Exception exception)
          {
              // handle ABP-wrapped Identity errors
              if (exception is AbpIdentityResultException abpIdentityEx)
              {
                  // Collect each error description and format as separate lines
                  var lines = abpIdentityEx
                      .IdentityResult
                      .Errors
                      .Select(e => L[e.Description].Value) // localize each description
                      .ToArray();

                  if (lines.Length > 0)
                  {
                      return string.Join(Environment.NewLine, lines);
                  }
              }

              // fallback to the default implementation
              return base.GetLocalizedExceptionMessage(exception);
          }
      }
  }
  • Then place Razor pages (e.g., Register.cshtml, Login.cshtml, ChangePassword.cshtml) under Pages/Account in your HttpApi.Host and set their @model to your derived PageModel (e.g., CustomRegisterModel : RegisterModel) which in turn can use the above override via a shared base.

  • ABP already converts IdentityResult to AbpIdentityResultException; you only need to detect it and build your own message list. AbpIdentityResultExtensions exposes helpers to format errors if you prefer to reuse.

  • Add a custom password validator and return your own error messages

    • Implement a custom IPasswordValidator<IdentityUser> (or derive from PasswordValidator<IdentityUser>) and return IdentityError items for your custom rules. Add it via PreConfigure to IdentityBuilder so it runs with the built-in validators.
using System.Threading.Tasks;
  using Microsoft.AspNetCore.Identity;
  using Volo.Abp.DependencyInjection;
  using Volo.Abp.Identity;

  public class MyPasswordValidator : IPasswordValidator<IdentityUser>, ITransientDependency
  {
      public Task<IdentityResult> ValidateAsync(UserManager<IdentityUser> manager, IdentityUser user, string password)
      {
          var errors = new List<IdentityError>();

          // Example: built-in checks are already configured by IdentityOptions.
          // Add your own custom rule:
          if (password?.Contains("my-disallowed-fragment", StringComparison.OrdinalIgnoreCase) == true)
          {
              errors.Add(new IdentityError
              {
                  Code = "MyCustomPasswordRule",
                  Description = "My custom password validation error message."
              });
          }

          return Task.FromResult(errors.Count == 0 ? IdentityResult.Success : IdentityResult.Failed(errors.ToArray()));
      }
  }
  • Register your validator:
using Microsoft.AspNetCore.Identity;
  using Microsoft.Extensions.DependencyInjection;
  using Volo.Abp.Identity;
  using Volo.Abp.Modularity;

  [DependsOn(typeof(AbpIdentityApplicationModule))]
  public class MyProjectIdentityModule : AbpModule
  {
      public override void PreConfigureServices(ServiceConfigurationContext context)
      {
          PreConfigure<IdentityBuilder>(builder =>
          {
              builder.AddPasswordValidator<MyPasswordValidator>();
              // you can also add user validators: .AddUserValidator<YourUserValidator>()
          });
      }
  }
  • Localization: you can localize your custom error’s Description with the ABP localization system (e.g., L["MyCustomPasswordRuleMessage"]) before returning it, or return a code and map it to a localized string in your overridden GetLocalizedExceptionMessage.

  • Notes on sources/extension points

    • ABP wraps IdentityResult errors into AbpIdentityResultException; you access the underlying IdentityResult.Errors to construct any UI you like (each error on a new line, bullet list, etc.).
    • If you want to keep the default toast but render multi-line text, ensure the UI uses the returned message from GetLocalizedExceptionMessage and preserves line breaks (e.g., white-space CSS or replacing “\n” with <br /> in Razor if needed).
  1. Sources
  • ABP IdentityResult helpers used by AbpIdentityResultException:
    • https://github.com/abpframework/abp/blob/dev/modules/identity/src/Volo.Abp.Identity.Domain/Microsoft/AspNetCore/Identity/AbpIdentityResultExtensions.cs#L112-L134
  • ABP Module system for PreConfigure/DependsOn:
    • https://abp.io/docs/latest/framework/architecture/modularity/basics
  • Customizing Account (login) pages and PageModels:
    • https://abp.io/community/articles/9a40f3cd

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution Based on ABP Account module behavior, the username/password form is rendered only when EnableLocalLogin is true for the current login request AND there is at least one valid login scheme available for the current client/redirect (e.g., no client configuration forbids local login). In production, the empty page typically happens for one of these reasons:
  • The MVC Account page renders nothing because Model.EnableLocalLogin is false and there are no external providers visible. This occurs when:
    • IdentityServer/OpenIddict client configuration has EnableLocalLogin/AllowLocalLogin disabled for that client.
    • The returnUrl belongs to a client that disallows local login.
    • Only external login schemes are active and none are visible due to configuration.
  • A customized Account Layout overrides hides the login form behind conditions that fail in production (multi-tenancy, missing tag helpers, missing scripts).
  • Static resources for the theme (LeptonX/LeptonXLite/Basic) are blocked or not served due to base path/CSP/proxy, preventing the form area from being shown if the page relies on client-side render areas.
  • Tenant resolution differs between SIT and PROD, causing CurrentTenant to be a tenant where settings differ (login disabled for that tenant).
  • View engine can’t process tag helpers because _ViewImports.cshtml is missing in your override folder in PROD deployment, so abp-script/style bundles aren’t injected and the form area stays empty.

Checklist to identify and fix:

  1. Check client’s AllowLocalLogin/EnableLocalLogin
  • If you use IdentityServer (ABP 7.x and earlier) or OpenIddict (ABP 8+), verify the client in database:
    • For IdentityServer: Clients table -> EnableLocalLogin must be true.
    • For OpenIddict: Check application’s settings; if you migrated from IdentityServer ensure local login isn’t disabled by a custom logic.
  • Ensure the login request has a returnUrl pointing to a client that allows local login. Test by visiting /Account/Login without returnUrl and see if the form appears.
  1. Verify ABP account settings for the effective tenant
  • Ensure AccountSettingNames.EnableLocalLogin is true for the tenant resolved in production. If multi-tenancy is enabled, confirm which tenant is resolved on /Account/Login in PROD and compare AbpSettings values for that tenant. Don’t rely only on Host or a different tenant.
  • Also check AccountSettingNames.ExternalLoginIsEnabled and IsSelfRegistrationEnabled do not affect visibility unintentionally.
  1. Confirm tenant resolution consistency
  • Make sure the same tenant name/cookie/query resolver works in PROD. If PROD resolves a different tenant (or host), the settings and client-permissions may differ.
  • If you depend on cookie/querystring tenant resolver, ensure your reverse proxy keeps those values and that the host name maps to the intended tenant.
  1. If you customized the Account layout/page, validate tag helpers and bundles
  • If you have overridden the login or account layout pages, ensure _ViewImports.cshtml exists in that folder with at least: @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap @addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling
  • Make sure the page includes: <abp-style-bundle ... /> <abp-script-bundle ... /> <script src="~/Abp/ApplicationLocalizationScript?cultureName=..."></script> <script src="~/Abp/ApplicationConfigurationScript"></script> <script src="~/Abp/ServiceProxyScript"></script> Missing these prevents proper rendering of form components in some themes.
  1. Theme static files and base path
  • If you use LeptonX/LeptonXLite, verify all static files are served in PROD:
    • Reverse proxy and CDN paths
    • Theming base href (AbpThemingOptions.BaseUrl) if your app is behind a sub-path
    • CSP headers not blocking inline styles/scripts used by the theme Use browser dev tools Network tab to ensure CSS/JS bundles load with 200 responses.
  1. External provider configuration fallback
  • If only external SSO is configured for the client and the provider is unavailable or misconfigured in PROD, ABP can hide both local and external options, resulting in a blank area. Confirm the external provider is configured the same as SIT, and consider enabling local login for fallback.
  1. Quick isolation tests
  • Open /Account/Login directly without returnUrl.
  • Temporarily force-enable local login in code at startup to confirm the path: Configure<AbpAccountOptions>(o => { o.EnableLocalLogin = true; }); If the form appears, the root cause is settings/client configuration per request.
  • Test with Host (no tenant) and with the exact tenant slug/cookie used in SIT.
  • Check logs around rendering of LoginModel (info/debug) to see EnableLocalLogin and VisibleExternalProviders values.

If you need to ensure the local form always appears for troubleshooting, you can temporarily override the login page as documented and explicitly render the form when you know it should be visible.

  1. Sources
  • How to customize/override the login page and ensure tag helpers/bundles are included:
    • https://abp.io/community/articles/9a40f3cd
  • LeptonX/Account layout override and importance of tag helpers and script bundles:
    • https://abp.io/support/questions/6571
  • Tenant switch and account layout placement in LeptonX (helps verify tenant-related visibility and layout overrides):
    • https://abp.io/support/questions/9283

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution In ABP 9.x you still add MVC Application Parts when you include a module from source, but you should use the ABP-provided extension AddApplicationPartIfNotExists on IMvcBuilder. You typically place it in your WebModule’s ConfigureServices.

Example for Account Pro (Public + Admin Web) on v9.3.6:

using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.AspNetCore.Mvc; // for AddApplicationPartIfNotExists
using Volo.Abp.Modularity;
using Volo.Abp.Account.Pro.Public.Web;
using Volo.Abp.Account.Pro.Admin.Web;

[DependsOn(
    typeof(AccountPublicWebModule),
    typeof(AccountAdminWebModule)
)]
public class MyProjectWebModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        // Ensure MVC can discover Razor Pages/Views/Controllers from the module source projects
        context.Services
            .AddMvc()
            .AddApplicationPartIfNotExists(typeof(AccountPublicWebModule).Assembly)
            .AddApplicationPartIfNotExists(typeof(AccountAdminWebModule).Assembly);
        
        // If you also have HttpApi Controllers from module source, keep conventional controllers:
        Configure<AbpAspNetCoreMvcOptions>(options =>
        {
            options.ConventionalControllers.Create(typeof(MyProjectWebModule).Assembly);
        });
    }
}

Notes:

  • The important piece is AddMvc().AddApplicationPartIfNotExists(assembly).
  • Use the actual module types from the source projects you added (e.g., AccountPublicWebModule, AccountAdminWebModule). Namespaces are Volo.Abp.Account.Pro.Public.Web and Volo.Abp.Account.Pro.Admin.Web.
  • Keep the standard ABP middleware order in Program/Startup and call app.UseConfiguredEndpoints() so Razor Pages endpoints are mapped.

If your host only uses the Public Web part, you can omit the Admin one and keep only AccountPublicWebModule’s assembly in AddApplicationPartIfNotExists.

  1. Sources
  • https://abp.io/docs/latest/modules/account-pro
  • https://abp.io/docs/latest/framework/architecture/modularity/basics
  • https://github.com/abpframework/abp

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution Based on similar internal cases and how Account Pro works when you switch from package to source, the login POST not hitting OnPostAsync and a silent redirect back to the home page almost always means one of the following in the Web host:
  • Missing middleware order (authentication/authorization/endpoints)
  • Missing MVC ApplicationPart (when module sources are added)
  • Mismatched cookie/auth schemes after bringing the module source
  • Antiforgery not being posted/validated because the form tag helper or antiforgery cookie isn’t present

Apply and verify these steps in your Web project that hosts the UI (the one that serves /Account/Login):

A) Ensure the required middleware order in Configure pipeline In your Web host (e.g., MyProject.Web), Configure method must include, in this order:

  • app.UseAbpRequestLocalization();
  • app.UseStaticFiles();
  • app.UseRouting();
  • app.UseAuthentication();
  • app.UseAbpOpenIddictValidation(); // if OpenIddict validation is used in the solution
  • app.UseAuthorization();
  • app.UseAuditing();
  • app.UseUnitOfWork();
  • app.UseConfiguredEndpoints();

A missing UseAuthentication/UseAuthorization or UseConfiguredEndpoints will result in POST not reaching the Razor Page handler.

B) Add MVC Application Parts for module source projects When you included Account.Pro source, ensure MVC discovers its pages/controllers:

public override void ConfigureServices(ServiceConfigurationContext context) { Configure<AbpAspNetCoreMvcOptions>(options => { options.ConventionalControllers.Create(typeof(MyProjectWebModule).Assembly); });

// Ensure MVC can locate Razor Pages/Views from module projects
context.Services.AddMvc()
    .AddApplicationPartIfNotExists(typeof(Volo.Abp.Account.Pro.Public.Web.AccountPublicWebModule).Assembly)
    .AddApplicationPartIfNotExists(typeof(Volo.Abp.Account.Pro.Admin.Web.AccountAdminWebModule).Assembly)
    .AddControllersAsServices();

}

If your module class names differ (due to namespace), add the corresponding Public.Web/Admin.Web assemblies from the Account.Pro source you added. Without these ApplicationParts, routing/view discovery can fail and the POST handler might not be mapped.

C) Verify authentication schemes after source inclusion Account.Pro’s login relies on Identity cookie scheme for local login and IdentityConstants.ExternalScheme for external flows. Ensure you didn’t inadvertently override DefaultScheme/DefaultChallengeScheme in your host. A safe configuration:

public override void ConfigureServices(ServiceConfigurationContext context) { Configure<AuthenticationOptions>(options => { // Do not set DefaultChallengeScheme to OIDC for the whole app unless you know why. // Let AccountPro render external providers and challenge only when selected. });

context.Services.AddAuthentication(); // keep defaults from ABP/Identity

}

If you integrate an external provider (OIDC, Azure AD), register it but don’t globally set DefaultChallengeScheme. For OIDC, make sure SignInScheme is IdentityConstants.ExternalScheme:

context.Services.AddAuthentication() .AddOpenIdConnect("oidc", options => { // ... your authority, client id/secret, scopes options.SignInScheme = Microsoft.AspNetCore.Identity.IdentityConstants.ExternalScheme; });

D) Confirm the Razor Page form posts antiforgery

  • In your overridden Login.cshtml, the form must be <form method="post"> and should include the antiforgery token. ABP’s tag helpers include this automatically if you use the standard markup copied from the module. Avoid removing it.

E) Check endpoint mapping for Razor Pages If you customized endpoint registration, ensure MapAbpEndpoints (or UseConfiguredEndpoints) is called. If you use endpoint routing explicitly, make sure you have endpoints.MapRazorPages(); or the ABP equivalent.

F) Clear caches and recompile

  • Delete bin/obj of all module source projects and Web project.
  • Ensure all old Volo.Abp.Account.* package references were replaced by ProjectReference (ABP Suite usually does this).
  • Rebuild the solution.

G) Quick diagnostics

  • Navigate to /Account/Login and open browser dev tools:
    • Ensure there is a RequestVerificationToken cookie and a hidden __RequestVerificationToken input in the form.
    • When clicking Login, verify the request is POST /Account/Login with status 302 only after your OnPostAsync runs. If you get 302 immediately and your breakpoint doesn’t hit, routing/middleware ordering or ApplicationPart is missing.
  • Enable detailed logs for Microsoft.AspNetCore.Routing and Microsoft.AspNetCore.Mvc.RazorPages to see whether the page handler is discovered.

If you also enabled “External Logins” using Account Pro settings and you recently upgraded, and you still get silent redirects, check your AbpUserLogins table for duplicated or stale IdentityUserLogin entries. Clearing conflicting entries fixed a similar issue after upgrades in another case.

  1. Sources
  • https://abp.io/docs/latest/modules/account-pro
  • https://docs.abp.io/en/abp/latest/Modules/Account
  • https://github.com/abpframework/abp

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution
  • Please contact the ABP team to manage license activations (add/remove developer machines). License administration (including clearing old machines to resolve ABP-LIC-0012) is handled by the support team.
  • Include in your message:
    • Organization: HQSOFTCompanyLimited
    • User: hanntd
    • Old machine to remove: Agent_Dev_01 (Ubuntu)
    • New machine: Agent_Win11_01
    • Error code: ABP-LIC-0012
  1. Sources
  • https://abp.io/contact

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  1. Solution
  • You do not need a custom IAuditingStore just because you want to persist audit logs into a different SQL Server database. ABP’s EF Core integration already supports multiple databases and separate DbContexts per module.

  • Recommended approach (ABP 9.1.1):

    1. Add the Audit Logging EF Core module to your EFCore layer:
[DependsOn(
      typeof(AbpEntityFrameworkCoreSqlServerModule),
      typeof(AbpEntityFrameworkCoreModule),
      typeof(AbpAuditLoggingEntityFrameworkCoreModule) // important
  )]
  public class YourAppEntityFrameworkCoreModule : AbpModule
  {
      public override void ConfigureServices(ServiceConfigurationContext context)
      {
          Configure<AbpDbContextOptions>(options =>
          {
              // Default DB for your main DbContext(s)
              options.Configure<YourAppDbContext>(c => c.UseSqlServer());

              // Configure the audit logging DbContext to use a different connection string
              options.Configure<AbpAuditLoggingDbContext>(c =>
              {
                  c.UseSqlServer();
              });

              // Fallback provider if needed
              options.UseSqlServer();
          });

          // Map connection strings by name (Default vs. AuditLogging)
          // appsettings.json:
          // "ConnectionStrings": {
          //   "Default": "Server=...;Database=MainDb;...",
          //   "AuditLogging": "Server=...;Database=AuditDb;..."
          // }
          Configure<AbpDbConnectionOptions>(opt =>
          {
              // Tell ABP to use "AuditLogging" connection for AbpAuditLoggingDbContext
              opt.Databases.Configure("AuditLogging", database =>
              {
                  database.MappedConnections.Add(typeof(AbpAuditLoggingDbContext));
              });
          });
      }
  }
  1. Set up connection strings in appsettings.json:
  • In your HttpApi.Host (and any process that writes audit logs):
{
    "ConnectionStrings": {
      "Default": "Server=.;Database=MainDb;Trusted_Connection=True;",
      "AuditLogging": "Server=.;Database=AuditDb;Trusted_Connection=True;"
    }
  }
  1. Ensure the audit tables are created in the separate DB:
  • Include the AbpAuditLoggingDbContext in your migration process so that:
    • AbpAuditLogs
    • AbpAuditLogActions
    • AbpEntityChanges
    • AbpEntityPropertyChanges are created in the AuditDb. Run migrations for the audit logging DbContext (or use ABP’s consolidated migrator if you already use it and it’s configured to handle multiple databases).
  1. Enable auditing as usual:
Configure<AbpAuditingOptions>(options =>
  {
      options.IsEnabled = true;
      options.EntityHistorySelectors.AddAllEntities();
      // etc.
  });

  public override void OnApplicationInitialization(ApplicationInitializationContext context)
  {
      var app = context.GetApplicationBuilder();
      app.UseAuditing(); // required
  }
  • Why no custom IAuditingStore is required:

    • The default AuditingStore provided by the Volo.Abp.AuditLogging module persists audit logs via IAuditLogRepository, which is backed by AbpAuditLoggingDbContext. By mapping AbpAuditLoggingDbContext to a different connection string (AuditLogging), the default store will automatically save to that separate database.
  • Optional: schema customization

    • If you want to change the schema for audit tables, set it early in Program.cs (or module pre-config) of all projects that reference AbpAuditLogging EFCore:
AbpAuditLoggingDbProperties.DbSchema = "audit";

Ensure your migrations align with the chosen schema.

  1. Sources
  • Validated: Entity Framework Core integration and multiple databases:
    • https://docs.abp.io/en/abp/latest/Entity-Framework-Core
  • Validated: Support guidance showing provider configuration for AbpAuditLoggingDbContext and auditing middleware:
    • https://abp.io/support/questions/6880

This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

Showing 1 to 10 of 713 entries
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.1.0-preview. Updated on December 15, 2025, 06:08
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.