Hi,
Your direction is correct — call IIdentityUserIntegrationService from your WorkItem service and fill the username on the DTO. Since you're on a Business license and your IdentityService uses the Pro packages, go with Volo.Abp.Identity.Pro.HttpApi.Client (not the open-source one) so the contracts stay consistent across services.
About why Suite doesn't list IdentityUser in that dropdown: in the microservice template, Identity runs as its own service with its own database, and your WorkItem service's Domain project does not (and should not) reference Volo.Abp.Identity.Pro.Domain. Suite hides it on purpose — cross-database EF navigation isn't possible here. Keep AssignedUserId as a plain Guid / Guid? and resolve the username at the application layer.
The username data lives in the IdentityService database. ABP already exposes a built-in integration endpoint for this (/integration-api/identity/users/search/by-ids) — you don't need to write anything on the Identity side, just consume it from your WorkItem service.
Three steps:
.csproj:<PackageReference Include="Volo.Abp.Identity.Pro.HttpApi.Client" Version="$(YourAbpVersion)" />
IIdentityUserIntegrationService client proxy in DI — without it the interface can't be resolved:using Volo.Abp.Identity;
[DependsOn(
// ...your existing dependencies
typeof(AbpIdentityHttpApiClientModule)
)]
public class WorkItemServiceModule : AbpModule
{
}
WorkItemsAppService and enrich the DTOs after the list query. Use SearchByIdsAsync so it's one remote call per page, not N+1:private readonly IIdentityUserIntegrationService _identityUserIntegrationService;
public WorkItemsAppService(
/* ...existing deps */,
IIdentityUserIntegrationService identityUserIntegrationService)
{
_identityUserIntegrationService = identityUserIntegrationService;
}
private async Task FillAssignedUserNamesAsync(IReadOnlyList<WorkItemDto> items)
{
var userIds = items
.Where(x => x.AssignedUserId.HasValue)
.Select(x => x.AssignedUserId!.Value)
.Distinct()
.ToArray();
if (userIds.Length == 0)
{
return;
}
var users = await _identityUserIntegrationService.SearchByIdsAsync(userIds);
var userMap = users.Items.ToDictionary(u => u.Id, u => u.UserName);
foreach (var item in items)
{
if (item.AssignedUserId.HasValue &&
userMap.TryGetValue(item.AssignedUserId.Value, out var userName))
{
item.AssignedUserName = userName;
}
}
}
Then call FillAssignedUserNamesAsync(result.Items) at the end of your list method (right before returning), and add an AssignedUserName property to WorkItemDto.
A few notes:
appsettings.json already has RemoteServices:AbpIdentity:BaseUrl pointing to your IdentityService, so no extra configuration is needed.UserData also carries Name, Surname, Email, IsActive etc., so you can map other fields the same way if you need them later.If anything doesn't work or you run into any other issues, feel free to let me know.
Thanks
Hi,
Access tokens issued by ABP / OpenIddict are standard self-contained JWTs and stay valid until they expire. A logout only clears the client-side cookies/session, it doesn't invalidate an already-issued JWT on the server side. This is by design in OAuth2 / OpenID Connect.
If you want already-issued tokens to be rejected as soon as the user logs out, ABP has this built in via the Session Management + Dynamic Claims feature. Once enabled, every authenticated request looks up the session id (sid) of the token against a server-side store (cache + DB) and rejects the token if the session is gone. Logout (or revoking a session from the Identity UI) removes the record, so the next API call with that token returns 401.
How it works:
sid claim is added and an IdentitySession record is created.IdentitySessionChecker validates the sid (via distributed cache, falling back to DB)./connect/endsession, or when an admin revokes the session from the Identity Users page, the record is removed and the cache is invalidated.Docs:
Starting from ABP v8.0 the startup templates already enable AbpClaimsPrincipalFactoryOptions.IsDynamicClaimsEnabled = true on the auth server / host modules, so if you created your solution with ABP 10 from the official template it should already be active. You can also verify the behavior from the UI: open the Identity → Users page, pick a user, open Sessions, and click Revoke — the user's existing access token will stop working on the next request.
A couple of things to confirm so I can point you at the right place:
app-nolayers-pro), layered (app-pro), tiered (separate AuthServer), or microservice./connect/endsession with id_token_hint (default for Angular, Blazor, MAUI templates), or does it only clear the token locally?options.IsDynamicClaimsEnabled = true is set on your auth server / host module.With those details I can tell you exactly where to look in your solution and what to change if the session check isn't kicking in.
Thanks
: )
Hi,
Glad you already had the override in place — we've added the missing translations for all bundled locales in the next release. I'll also pass along the suggestion about surfacing new keys.
Thanks
Hi,
The localization key is Saas::InviteUser. The description line right under it uses Saas::InviteUserDescription.
These two entries were added recently, but the non-English locale files in the Saas resource still carry the English text — that's why your menu shows up in English even when the rest is in Finnish. We'll ship translations for all locales in the next release. In the meantime you can override them in your project following this article:
Localization/SaasOverride/fi.json to your .Domain.Shared project:{
"culture": "fi",
"texts": {
"InviteUser": "Kutsu admin-käyttäjä liittymään vuokralaiseen",
"InviteUserDescription": "Tämä lähettää sähköpostiviestin, ja kun käyttäjä hyväksyy sen, hänestä tulee tämän vuokralaisen ylläpitäjä (myönnetään admin-rooli)."
}
}
<YourProject>.Domain.Shared.csproj embed the new folder:<ItemGroup>
<EmbeddedResource Include="Localization\SaasOverride\*.json" />
<Content Remove="Localization\SaasOverride\*.json" />
</ItemGroup>
<YourProject>DomainSharedModule.ConfigureServices register the override against SaasResource:using Volo.Saas.Localization;
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Get<SaasResource>()
.AddVirtualJson("/Localization/SaasOverride");
});
Thanks
Hi,
The CMS Kit Pro Newsletter module is a subscription store — it handles subscribe / unsubscribe, the preferences UI, the admin list, and CSV export. The only emails it sends are lifecycle ones (double opt-in confirmation, "subscription confirmed", "preferences updated", "unsubscribed"). There's no broadcast / campaign API, no compose UI, and no admin menu to send a newsletter to your subscribers — that's outside the module's scope.
To send newsletters, build it on top of ABP's own building blocks. The module uses these same pieces internally for its lifecycle emails, so you're on the same path:
IRepository<NewsletterRecord, Guid> and filter by IsEmailConfirmed (and by Preference if you keep multiple lists).ITemplateRenderer (ABP Text Templating) so the template can be edited later without redeploying.IEmailSender.QueueAsync(...). QueueAsync goes through ABP's background job system, so the request doesn't block and large lists won't time out.A minimal sender service:
using System;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Emailing;
using Volo.Abp.TextTemplating;
using Volo.CmsKit.Newsletters;
public class NewsletterBroadcastService : ApplicationService
{
private readonly IRepository<NewsletterRecord, Guid> _newsletterRepository;
private readonly IEmailSender _emailSender;
private readonly ITemplateRenderer _templateRenderer;
public NewsletterBroadcastService(
IRepository<NewsletterRecord, Guid> newsletterRepository,
IEmailSender emailSender,
ITemplateRenderer templateRenderer)
{
_newsletterRepository = newsletterRepository;
_emailSender = emailSender;
_templateRenderer = templateRenderer;
}
public virtual async Task BroadcastAsync(string preference, string subject, object templateModel)
{
var subscribers = await _newsletterRepository.GetListAsync(
x => x.IsEmailConfirmed == true &&
x.Preferences.Any(p => p.Preference == preference));
var body = await _templateRenderer.RenderAsync("MyNewsletterTemplate", templateModel);
foreach (var subscriber in subscribers)
{
await _emailSender.QueueAsync(subscriber.EmailAddress, subject, body);
}
}
}
A few things to keep in mind: only send to confirmed subscribers, respect their preferences and any unsubscribe state, and stay inside the current tenant boundary (the repository already filters by CurrentTenant). For larger lists, push each per-subscriber send into a background job (IBackgroundJobManager.EnqueueAsync(...)) instead of looping in the service, so each one gets its own retry / failure handling.
Anything beyond that — campaign history, scheduling, A/B testing, open / click tracking, bounce handling — isn't part of the module, so you'll need to add it yourself (your own tables, jobs, etc.).
Thanks
Hi,
The external login button on the Login/Register/ExternalLogins pages was restyled in 10.x. The new template uses a full-width, rounded-pill button with a bg-info bg-opacity-10 background and the AbpAccount::SignInWith localization key (which renders as "Sign in with {0}"). To bring back the old outlined button look from 9.x without overriding the entire Razor Page (so future ABP enhancements like passkey/email login keep flowing in), you can do it with a CSS rule + a localization override on AccountResource.
1) CSS (add to wwwroot/global-styles.css in your AuthServer project, or any other CSS file that is loaded on the account pages):
.account-module-form form[method="post"] button[name="provider"] {
display: inline-block !important;
width: auto !important;
background-color: transparent !important;
color: var(--bs-primary) !important;
border: 1px solid var(--bs-primary) !important;
border-radius: 0.375rem !important;
padding: 0.25rem 0.5rem !important;
font-size: 0.875rem !important;
margin-top: 0.5rem;
margin-right: 0.5rem;
}
.account-module-form form[method="post"] button[name="provider"]:hover {
background-color: var(--bs-primary) !important;
color: #fff !important;
}
The CSS above uses var(--bs-primary), which matches the LeptonX accent color in 10.x. If you want a closer match to the exact color from your 9.x screenshot, replace var(--bs-primary) with a specific hex (e.g. #1f1f1f for the dark outline look) in both the color/border rules and the :hover background-color.
2) Localization override for AbpAccount::SignInWith so the button reads "Microsoft Entra ID" instead of "Sign in with Microsoft Entra ID".
Following the pattern from How to override localization strings of depending modules, do this in your <YourProject>.Domain.Shared project (so it applies to AuthServer, HttpApi.Host and any other host in one place):
a) Add Localization/AccountOverride/en.json:
{
"culture": "en",
"texts": {
"SignInWith": "{0}"
}
}
b) In <YourProject>.Domain.Shared.csproj, embed the new folder alongside your existing localization entry, and add a package reference for AccountResource (the resource lives in Volo.Abp.Account.Pro.Shared.Application.Contracts, which is not in the default Domain.Shared reference chain):
<ItemGroup>
<EmbeddedResource Include="Localization\<YourProject>\*.json" />
<Content Remove="Localization\<YourProject>\*.json" />
<EmbeddedResource Include="Localization\AccountOverride\*.json" />
<Content Remove="Localization\AccountOverride\*.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Volo.Abp.Account.Pro.Shared.Application.Contracts" />
</ItemGroup>
c) In <YourProject>DomainSharedModule.ConfigureServices, add AccountResource as another contributor target:
using Volo.Abp.Account.Localization;
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Add<<YourProject>Resource>("en")
.AddBaseTypes(typeof(AbpValidationResource))
.AddVirtualJson("/Localization/<YourProject>");
options.Resources
.Get<AccountResource>()
.AddVirtualJson("/Localization/AccountOverride");
options.DefaultResourceType = typeof(<YourProject>Resource);
});
For other cultures, add zh-Hans.json, tr.json, etc. under /Localization/AccountOverride with the same shape and the override will apply per culture.
I tested this in a fresh 10.x Angular + Tiered solution and the button renders as an outline style with just the provider display name, without touching Login.cshtml/Register.cshtml/ExternalLogins.cshtml.
Thanks
Hi,
There's no auto-discover mechanism that turns SettingDefinitions into admin UI tabs. SettingDefinitionProvider only registers the setting on the backend (and exposes it to clients when isVisibleToClients: true); it doesn't render anything by itself.
On the React UI specifically, the Admin Console at /admin-console is shipped as a prebuilt SPA inside the Volo.Abp.AdminConsole NuGet package and is meant to stay in sync with ABP updates. Its tabs (Account / Emailing / Feature management / Identity management / SaaS management) are fixed; there's no contributor API to inject a custom tab. See the docs:
The recommended approach is to keep your SettingDefinitionProvider / PermissionDefinitionProvider exactly as you have them, add a small AppService to read/write the values, and build the page in the main React app (the react/ folder in your solution — that's the code you own and can freely customize).
Step 1 — Add an AppService in *.Application (and a DTO in *.Application.Contracts):
// *.Application.Contracts/SendGrid/SendGridSettingsDto.cs
public class SendGridSettingsDto
{
public bool Enabled { get; set; }
public string? FromName { get; set; }
public string? FromAddress { get; set; }
public string? ApiKey { get; set; }
}
// *.Application.Contracts/SendGrid/ISendGridSettingsAppService.cs
public interface ISendGridSettingsAppService : IApplicationService
{
Task<SendGridSettingsDto> GetAsync();
Task UpdateAsync(SendGridSettingsDto input);
}
// *.Application/SendGrid/SendGridSettingsAppService.cs
[Authorize(SendGridPermissions.SendGrid)]
public class SendGridSettingsAppService : YourProjectNameAppService, ISendGridSettingsAppService
{
private readonly ISettingManager _settingManager;
public SendGridSettingsAppService(ISettingManager settingManager)
{
_settingManager = settingManager;
}
public async Task<SendGridSettingsDto> GetAsync()
{
return new SendGridSettingsDto
{
Enabled = bool.Parse(await SettingProvider.GetOrNullAsync(SendGridSettings.Enabled) ?? "false"),
FromName = await SettingProvider.GetOrNullAsync(SendGridSettings.FromName),
FromAddress = await SettingProvider.GetOrNullAsync(SendGridSettings.FromAddress),
ApiKey = await SettingProvider.GetOrNullAsync(SendGridSettings.ApiKey),
};
}
public async Task UpdateAsync(SendGridSettingsDto input)
{
await _settingManager.SetGlobalAsync(SendGridSettings.Enabled, input.Enabled.ToString());
await _settingManager.SetGlobalAsync(SendGridSettings.FromName, input.FromName ?? string.Empty);
await _settingManager.SetGlobalAsync(SendGridSettings.FromAddress, input.FromAddress ?? string.Empty);
await _settingManager.SetGlobalAsync(SendGridSettings.ApiKey, input.ApiKey ?? string.Empty);
}
}
ABP's conventional controllers will expose this as GET / PUT /api/app/send-grid-settings automatically — no extra routing needed. isEncrypted: true on the ApiKey setting is handled transparently by ISettingManager, so you get plain text in the DTO and an encrypted value in the database.
Step 2 — Add an API client in react/src/lib/api/sendgrid.ts:
import { api } from './axios'
export interface SendGridSettingsDto {
enabled: boolean
fromName: string | null
fromAddress: string | null
apiKey: string | null
}
export const sendGridApi = {
get: () => api.get<SendGridSettingsDto>('/app/send-grid-settings').then((r) => r.data),
update: (input: SendGridSettingsDto) => api.put('/app/send-grid-settings', input),
}
Note the path is /app/send-grid-settings, not /api/app/send-grid-settings — the axios baseURL already includes /api.
Step 3 — Add the page in react/src/pages/sendgrid/SendGridSettingsPage.tsx:
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { sendGridApi } from '@/lib/api/sendgrid'
export function SendGridSettingsPage() {
const [form, setForm] = useState({ enabled: false, fromName: '', fromAddress: '', apiKey: '' })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
sendGridApi.get()
.then((d) => setForm({
enabled: d.enabled,
fromName: d.fromName ?? '',
fromAddress: d.fromAddress ?? '',
apiKey: d.apiKey ?? '',
}))
.catch(() => toast.error('Failed to load SendGrid settings'))
.finally(() => setLoading(false))
}, [])
async function onSave() {
setSaving(true)
try {
await sendGridApi.update(form)
toast.success('SendGrid settings saved')
} catch {
toast.error('Failed to save SendGrid settings')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">SendGrid Settings</h1>
<Card>
<CardHeader><CardTitle>Configuration</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Checkbox id="enabled" checked={form.enabled} disabled={loading}
onCheckedChange={(v) => setForm({ ...form, enabled: v === true })} />
<Label htmlFor="enabled">Enabled</Label>
</div>
<div className="space-y-2">
<Label htmlFor="fromName">From Name</Label>
<Input id="fromName" value={form.fromName} disabled={loading}
onChange={(e) => setForm({ ...form, fromName: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="fromAddress">From Address</Label>
<Input id="fromAddress" type="email" value={form.fromAddress} disabled={loading}
onChange={(e) => setForm({ ...form, fromAddress: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Input id="apiKey" type="password" value={form.apiKey} disabled={loading}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })} />
</div>
<Button onClick={onSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</CardContent>
</Card>
</div>
)
}
Step 4 — Register the route in react/src/routes/router.tsx:
import { SendGridSettingsPage } from '@/pages/sendgrid/SendGridSettingsPage'
const sendGridSettingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sendgrid-settings',
component: SendGridSettingsPage,
beforeLoad: createPermissionGuard('SettingManagement.SendGrid'),
})
const routeTree = rootRoute.addChildren([
indexRoute,
forbiddenRoute,
accountRoute,
identityRoute,
sendGridSettingsRoute,
])
Step 5 — Add a sidebar menu item in react/src/lib/routing/route-config.ts:
import { Mail } from 'lucide-react'
// inside routeConfig:
{
path: '/sendgrid-settings',
nameKey: 'Menu:SendGridSettings',
icon: Mail,
order: 7,
requiredPolicy: 'SettingManagement.SendGrid',
},
Then add "Menu:SendGridSettings": "SendGrid Settings" to react/src/locales/en.json (and the other locale files you support).
That's it — the user will see a SendGrid Settings entry in the sidebar (gated by your SettingManagement.SendGrid permission), the page calls your AppService, and ISettingManager handles persistence including the encrypted API key.
Here's how it looks in a fresh 10.4.0 React tiered template I built to confirm the flow end to end:
Thanks
Hi,
Following up — the docs PR is open: https://github.com/abpframework/abp/pull/25505
It adds a dedicated Application URLs page covering AppUrlOptions, IAppUrlProvider, multi-tenant placeholders, host fallback, and RedirectAllowedUrls, plus short cross-references from the multi-tenancy, Account, emailing and microservice domain-resolving pages so it's easy to find from where you were looking.
Thanks