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
Hi,
linux-x64 publish 没有 Microsoft.Win32.Registry.dll 是正常的,这个包是 Windows 注册表 API 的 placeholder,linux 平台本来就不会复制 dll,不是你部署掉了文件。
真正崩的地方在 ErrorOccurredWhileRefreshingKeyRing,意思是 DataProtection 刷新 key ring 已经失败了,再去打 error 日志的时候 logger 的 type init 又因为引用了 Microsoft.Win32.Registry 类型崩了。所以你看到的 dll 找不到是后面的错,前面 key ring 刷新失败的真错被盖住了。
你说重启 docker 就恢复了,这个细节挺关键,多半是第一次启动 Redis 还没就绪 AuthServer 就跑起来了,key ring 第一次刷新失败;重启的时候 Redis 已经 ready 了,就正常。
为了确认,麻烦发一下:
docker-compose.yml 里 AuthServer + Redis 那部分(脱敏),主要看 depends_on / healthcheckMicrosoft.AspNetCore.DataProtection.* 和 StackExchange.Redis 有没有 warning / errorProgram.cs 或 AuthServerModule.cs 里 DataProtection 的配置代码,你之前同时说了 Redis 和 PersistKeysToFileSystem,到底用哪个PersistKeysToFileSystem,DataProtection-Keys 目录在容器里有没有挂 volume,没挂的话每次重启 key 都会重置也可以先试下让 AuthServer 等 Redis 健康再启动:
services:
auth-server:
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
首次启动就正常的话,基本就是这个原因。
不方便贴可以发邮件 liming.ma@volosoft.com。
Thanks