feat: Enhance Send Engine integration with webhook publishing and configuration options
This commit is contained in:
parent
8c6e3c550f
commit
ae6edae39c
@ -1,2 +1,4 @@
|
|||||||
ASPNETCORE_ENVIRONMENT=Development
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||||
|
SendEngine__BaseUrl=http://localhost:6060
|
||||||
|
SendEngine__WebhookSecret=change-me
|
||||||
|
|||||||
8
AGENTS.md
Normal file
8
AGENTS.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Dotnet Execution Policy
|
||||||
|
|
||||||
|
- For `.NET` commands (`dotnet restore`, `dotnet build`, `dotnet test`, `dotnet run`), run in sandbox first.
|
||||||
|
- If the command fails or hangs due to sandbox limits (for example restore/build stalls), rerun with `sandbox_permissions: "require_escalated"`.
|
||||||
|
- The escalation request must include a short justification explaining that sandbox restrictions are blocking normal .NET execution.
|
||||||
|
- Do not change project paths or command intent when escalating; rerun the same command with elevated permissions.
|
||||||
@ -43,7 +43,7 @@
|
|||||||
- roles / user_roles (Identity)
|
- roles / user_roles (Identity)
|
||||||
- id, name, created_at
|
- id, name, created_at
|
||||||
- OpenIddictApplications
|
- OpenIddictApplications
|
||||||
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties
|
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|webhook_outbound`)
|
||||||
- OpenIddictAuthorizations
|
- OpenIddictAuthorizations
|
||||||
- id, application_id, status, subject, type, scopes
|
- id, application_id, status, subject, type, scopes
|
||||||
- OpenIddictTokens
|
- OpenIddictTokens
|
||||||
@ -100,7 +100,8 @@
|
|||||||
### 6.6 Send Engine 事件同步(Member Center → Send Engine)
|
### 6.6 Send Engine 事件同步(Member Center → Send Engine)
|
||||||
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`)
|
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`)
|
||||||
2) 以 webhook 推送至 Send Engine(簽章與重放防護)
|
2) 以 webhook 推送至 Send Engine(簽章與重放防護)
|
||||||
3) Send Engine 驗證 tenant scope,更新本地名單快照
|
3) `X-Client-Id` 使用 Send Engine `auth_clients.id`(可按 tenant 做設定覆蓋)
|
||||||
|
4) Send Engine 驗證 tenant scope,更新本地名單快照
|
||||||
|
|
||||||
### 6.7 Send Engine 退信/黑名單回寫(選用)
|
### 6.7 Send Engine 退信/黑名單回寫(選用)
|
||||||
1) Send Engine 判定黑名單(例如 hard bounce / complaint)
|
1) Send Engine 判定黑名單(例如 hard bounce / complaint)
|
||||||
@ -138,6 +139,7 @@
|
|||||||
|
|
||||||
### Auth / Scope
|
### Auth / Scope
|
||||||
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
||||||
|
- OAuth Client 需區分用途:`tenant_api` 與 `webhook_outbound`(禁止混用)
|
||||||
- 新增 scope:`newsletter:list.read`、`newsletter:events.read`
|
- 新增 scope:`newsletter:list.read`、`newsletter:events.read`
|
||||||
- 新增 scope:`newsletter:events.write`
|
- 新增 scope:`newsletter:events.write`
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,10 @@
|
|||||||
|
|
||||||
## F-10 Send Engine 事件同步(Member Center → Send Engine)
|
## F-10 Send Engine 事件同步(Member Center → Send Engine)
|
||||||
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`)
|
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`)
|
||||||
- [API] Send Engine 驗證 tenant scope + 簽章/重放防護
|
- [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id`
|
||||||
|
- [API] `X-Client-Id` 對應 Send Engine `auth_clients.id`(UUID)
|
||||||
|
- [API] Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 選擇 header 值
|
||||||
|
- [API] Send Engine 驗證簽章 + timestamp + nonce(重放防護)後入庫
|
||||||
- [API] Send Engine 更新名單快照
|
- [API] Send Engine 更新名單快照
|
||||||
|
|
||||||
## F-11 黑名單回寫(Send Engine → Member Center)
|
## F-11 黑名單回寫(Send Engine → Member Center)
|
||||||
|
|||||||
@ -40,8 +40,16 @@
|
|||||||
```
|
```
|
||||||
ASPNETCORE_ENVIRONMENT=Development
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||||
|
SendEngine__BaseUrl=http://localhost:6060
|
||||||
|
SendEngine__WebhookSecret=change-me
|
||||||
|
SendEngine__TenantWebhookClientIds__REPLACE_WITH_TENANT_ID=11111111-1111-1111-1111-111111111111
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`SendEngine` 設定說明:
|
||||||
|
- `SendEngine__BaseUrl`: Send Engine API base URL
|
||||||
|
- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致
|
||||||
|
- `SendEngine__TenantWebhookClientIds__{tenant_uuid}`: 每個 tenant 對應的 `X-Client-Id`(Send Engine `auth_clients.id`)
|
||||||
|
|
||||||
### 1) `installer init`
|
### 1) `installer init`
|
||||||
用途:首次安裝(含 migrations + seed + superuser)
|
用途:首次安裝(含 migrations + seed + superuser)
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,7 @@ End User
|
|||||||
租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。
|
租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。
|
||||||
|
|
||||||
- 向 Member Center 申請 OAuth Client
|
- 向 Member Center 申請 OAuth Client
|
||||||
|
- OAuth Client 必須使用 `usage=tenant_api`(不可使用 `webhook_outbound`)
|
||||||
- scope 依用途最小化
|
- scope 依用途最小化
|
||||||
|
|
||||||
建議 scopes:
|
建議 scopes:
|
||||||
|
|||||||
@ -41,6 +41,35 @@
|
|||||||
- 與訂閱者資料(preferences、unsubscribe token)相關的查詢與寫入,一律必須帶 `list_id + email` 做租戶邊界約束。
|
- 與訂閱者資料(preferences、unsubscribe token)相關的查詢與寫入,一律必須帶 `list_id + email` 做租戶邊界約束。
|
||||||
- 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。
|
- 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。
|
||||||
|
|
||||||
|
## Webhook Auth(Member Center -> Send Engine)
|
||||||
|
- Header(對齊 Send Engine 規格):
|
||||||
|
- `X-Signature`
|
||||||
|
- `X-Timestamp`
|
||||||
|
- `X-Nonce`
|
||||||
|
- `X-Client-Id`
|
||||||
|
- `X-Client-Id` 來源:
|
||||||
|
- 由 Send Engine 的 `auth_clients.id`(UUID)提供
|
||||||
|
- Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 設定每個租戶的對應值
|
||||||
|
- 簽章建議:
|
||||||
|
- `HMAC-SHA256(secret, "{raw_body}")`(對齊 Send Engine 驗證器)
|
||||||
|
- 驗證規則:
|
||||||
|
- timestamp 在允許時間窗(例如 ±5 分鐘)
|
||||||
|
- nonce 不可重複(防重放)
|
||||||
|
- `X-Client-Id` 必須存在且 active,且 tenant 綁定一致
|
||||||
|
- signature 必須匹配
|
||||||
|
|
||||||
|
## OAuth Client 用途分離(強制)
|
||||||
|
- `usage=tenant_api`:
|
||||||
|
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
|
||||||
|
- scope 僅給業務所需(如 `newsletter:send.write`、`newsletter:list.read`)
|
||||||
|
- `usage=webhook_outbound`:
|
||||||
|
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
|
||||||
|
- 不可用於租戶 API 呼叫
|
||||||
|
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
|
||||||
|
- 管理規則:
|
||||||
|
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||||
|
- secret 分開輪替,禁止共用
|
||||||
|
|
||||||
## 待新增 API / Auth(規劃中)
|
## 待新增 API / Auth(規劃中)
|
||||||
### API
|
### API
|
||||||
- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用)
|
- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用)
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
### 管理者端
|
### 管理者端
|
||||||
- 租戶管理(Tenant CRUD)
|
- 租戶管理(Tenant CRUD)
|
||||||
- OAuth Client 管理(redirect_uris / scopes / client_id / client_secret)
|
- OAuth Client 管理(usage / redirect_uris / scopes / client_id / client_secret)
|
||||||
- 電子報清單管理(Lists CRUD)
|
- 電子報清單管理(Lists CRUD)
|
||||||
- 訂閱查詢 / 匯出
|
- 訂閱查詢 / 匯出
|
||||||
- 審計紀錄查詢
|
- 審計紀錄查詢
|
||||||
@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
### 管理者端(統一 UI)
|
### 管理者端(統一 UI)
|
||||||
- UC-11 租戶管理: `/admin/tenants`
|
- UC-11 租戶管理: `/admin/tenants`
|
||||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉)
|
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;需選 `usage=tenant_api` 或 `usage=webhook_outbound`)
|
||||||
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
||||||
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
||||||
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
||||||
|
|||||||
@ -466,8 +466,25 @@ paths:
|
|||||||
/webhooks/subscriptions:
|
/webhooks/subscriptions:
|
||||||
post:
|
post:
|
||||||
summary: (NOTE) Member Center -> Send Engine subscription events webhook
|
summary: (NOTE) Member Center -> Send Engine subscription events webhook
|
||||||
description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay.
|
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID).
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- in: header
|
||||||
|
name: X-Signature
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- in: header
|
||||||
|
name: X-Timestamp
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- in: header
|
||||||
|
name: X-Nonce
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
- in: header
|
||||||
|
name: X-Client-Id
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -481,8 +498,25 @@ paths:
|
|||||||
/webhooks/lists/full-sync:
|
/webhooks/lists/full-sync:
|
||||||
post:
|
post:
|
||||||
summary: (NOTE) Member Center -> Send Engine full list sync webhook
|
summary: (NOTE) Member Center -> Send Engine full list sync webhook
|
||||||
description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay.
|
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID).
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- in: header
|
||||||
|
name: X-Signature
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- in: header
|
||||||
|
name: X-Timestamp
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- in: header
|
||||||
|
name: X-Nonce
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
- in: header
|
||||||
|
name: X-Client-Id
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -871,5 +905,6 @@ components:
|
|||||||
id: { type: string }
|
id: { type: string }
|
||||||
tenant_id: { type: string }
|
tenant_id: { type: string }
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
|
usage: { type: string, enum: [tenant_api, webhook_outbound] }
|
||||||
redirect_uris: { type: array, items: { type: string } }
|
redirect_uris: { type: array, items: { type: string } }
|
||||||
client_type: { type: string, enum: [public, confidential] }
|
client_type: { type: string, enum: [public, confidential] }
|
||||||
|
|||||||
@ -44,8 +44,7 @@ builder.Services.AddOpenIddict()
|
|||||||
.AddCore(options =>
|
.AddCore(options =>
|
||||||
{
|
{
|
||||||
options.UseEntityFrameworkCore()
|
options.UseEntityFrameworkCore()
|
||||||
.UseDbContext<MemberCenterDbContext>()
|
.UseDbContext<MemberCenterDbContext>();
|
||||||
.ReplaceDefaultEntities<Guid>();
|
|
||||||
})
|
})
|
||||||
.AddServer(options =>
|
.AddServer(options =>
|
||||||
{
|
{
|
||||||
@ -90,6 +89,9 @@ builder.Services.AddScoped<INewsletterService, NewsletterService>();
|
|||||||
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
||||||
builder.Services.AddScoped<ITenantService, TenantService>();
|
builder.Services.AddScoped<ITenantService, TenantService>();
|
||||||
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
||||||
|
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||||
|
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||||
|
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
using MemberCenter.Application.Models.Newsletter;
|
||||||
|
|
||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface ISendEngineWebhookPublisher
|
||||||
|
{
|
||||||
|
Task PublishSubscriptionEventAsync(
|
||||||
|
string eventType,
|
||||||
|
Guid tenantId,
|
||||||
|
SubscriptionDto subscription,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
namespace MemberCenter.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
public sealed class SendEngineWebhookOptions
|
||||||
|
{
|
||||||
|
public string? BaseUrl { get; set; }
|
||||||
|
public string SubscriptionEventsPath { get; set; } = "/webhooks/subscriptions";
|
||||||
|
public string? WebhookSecret { get; set; }
|
||||||
|
public Dictionary<string, string> TenantWebhookClientIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ using MemberCenter.Domain.Constants;
|
|||||||
using MemberCenter.Domain.Entities;
|
using MemberCenter.Domain.Entities;
|
||||||
using MemberCenter.Infrastructure.Persistence;
|
using MemberCenter.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@ -19,11 +20,19 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
|
|
||||||
private readonly MemberCenterDbContext _dbContext;
|
private readonly MemberCenterDbContext _dbContext;
|
||||||
private readonly IEmailBlacklistService _emailBlacklist;
|
private readonly IEmailBlacklistService _emailBlacklist;
|
||||||
|
private readonly ISendEngineWebhookPublisher _webhookPublisher;
|
||||||
|
private readonly ILogger<NewsletterService> _logger;
|
||||||
|
|
||||||
public NewsletterService(MemberCenterDbContext dbContext, IEmailBlacklistService emailBlacklist)
|
public NewsletterService(
|
||||||
|
MemberCenterDbContext dbContext,
|
||||||
|
IEmailBlacklistService emailBlacklist,
|
||||||
|
ISendEngineWebhookPublisher webhookPublisher,
|
||||||
|
ILogger<NewsletterService> logger)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_emailBlacklist = emailBlacklist;
|
_emailBlacklist = emailBlacklist;
|
||||||
|
_webhookPublisher = webhookPublisher;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
|
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
|
||||||
@ -107,7 +116,9 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
confirmToken.ConsumedAt = DateTimeOffset.UtcNow;
|
confirmToken.ConsumedAt = DateTimeOffset.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
return MapSubscription(confirmToken.Subscription);
|
var confirmed = MapSubscription(confirmToken.Subscription);
|
||||||
|
await PublishSubscriptionEventSafeAsync("subscription.activated", confirmed);
|
||||||
|
return confirmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
|
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
|
||||||
@ -131,7 +142,9 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
|
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
return MapSubscription(unsubscribeToken.Subscription);
|
var unsubscribed = MapSubscription(unsubscribeToken.Subscription);
|
||||||
|
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", unsubscribed);
|
||||||
|
return unsubscribed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email)
|
public async Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email)
|
||||||
@ -198,7 +211,9 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
subscription.Preferences = ToJsonDocument(preferences);
|
subscription.Preferences = ToJsonDocument(preferences);
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
return MapSubscription(subscription);
|
var updated = MapSubscription(subscription);
|
||||||
|
await PublishSubscriptionEventSafeAsync("preferences.updated", updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId)
|
public async Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId)
|
||||||
@ -253,4 +268,22 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
.Include(t => t.Subscription)
|
.Include(t => t.Subscription)
|
||||||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
|
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PublishSubscriptionEventSafeAsync(string eventType, SubscriptionDto subscription)
|
||||||
|
{
|
||||||
|
var tenantId = await _dbContext.NewsletterLists
|
||||||
|
.Where(x => x.Id == subscription.ListId)
|
||||||
|
.Select(x => x.TenantId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (tenantId == Guid.Empty)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Skip webhook publish because list {ListId} has no tenant mapping",
|
||||||
|
subscription.ListId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _webhookPublisher.PublishSubscriptionEventAsync(eventType, tenantId, subscription);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,126 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using MemberCenter.Application.Abstractions;
|
||||||
|
using MemberCenter.Application.Models.Newsletter;
|
||||||
|
using MemberCenter.Infrastructure.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace MemberCenter.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IOptions<SendEngineWebhookOptions> _options;
|
||||||
|
private readonly ILogger<SendEngineWebhookPublisher> _logger;
|
||||||
|
|
||||||
|
public SendEngineWebhookPublisher(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IOptions<SendEngineWebhookOptions> options,
|
||||||
|
ILogger<SendEngineWebhookPublisher> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishSubscriptionEventAsync(
|
||||||
|
string eventType,
|
||||||
|
Guid tenantId,
|
||||||
|
SubscriptionDto subscription,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(options.BaseUrl)
|
||||||
|
|| string.IsNullOrWhiteSpace(options.WebhookSecret))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId = ResolveWebhookClientId(options, tenantId);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Skip webhook publish: missing webhook client id for tenant {TenantId}", tenantId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
event_id = Guid.NewGuid(),
|
||||||
|
event_type = eventType,
|
||||||
|
tenant_id = tenantId,
|
||||||
|
list_id = subscription.ListId,
|
||||||
|
subscriber = new
|
||||||
|
{
|
||||||
|
id = subscription.Id,
|
||||||
|
email = subscription.Email,
|
||||||
|
status = subscription.Status,
|
||||||
|
preferences = subscription.Preferences
|
||||||
|
},
|
||||||
|
occurred_at = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
var body = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||||
|
var nonce = Guid.NewGuid().ToString();
|
||||||
|
var signature = ComputeHmacHex(options.WebhookSecret, json);
|
||||||
|
|
||||||
|
var endpoint = BuildEndpoint(options.BaseUrl, options.SubscriptionEventsPath);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||||
|
{
|
||||||
|
Content = body
|
||||||
|
};
|
||||||
|
request.Headers.Add("X-Signature", signature);
|
||||||
|
request.Headers.Add("X-Timestamp", timestamp);
|
||||||
|
request.Headers.Add("X-Nonce", nonce);
|
||||||
|
request.Headers.Add("X-Client-Id", clientId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Send Engine webhook failed. Status={StatusCode}, TenantId={TenantId}, EventType={EventType}, Body={Body}",
|
||||||
|
(int)response.StatusCode,
|
||||||
|
tenantId,
|
||||||
|
eventType,
|
||||||
|
responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Send Engine webhook publish threw an exception for tenant {TenantId}, event {EventType}", tenantId, eventType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildEndpoint(string baseUrl, string path)
|
||||||
|
{
|
||||||
|
var normalizedBase = baseUrl.TrimEnd('/');
|
||||||
|
var normalizedPath = path.StartsWith('/') ? path : $"/{path}";
|
||||||
|
return normalizedBase + normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveWebhookClientId(SendEngineWebhookOptions options, Guid tenantId)
|
||||||
|
{
|
||||||
|
var tenantKey = tenantId.ToString();
|
||||||
|
if (options.TenantWebhookClientIds.TryGetValue(tenantKey, out var tenantClientId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(tenantClientId))
|
||||||
|
{
|
||||||
|
return tenantClientId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeHmacHex(string secret, string payload)
|
||||||
|
{
|
||||||
|
var key = Encoding.UTF8.GetBytes(secret);
|
||||||
|
using var hmac = new HMACSHA256(key);
|
||||||
|
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
using MemberCenter.Web.Models.Admin;
|
using MemberCenter.Web.Models.Admin;
|
||||||
|
using MemberCenter.Application.Abstractions;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using OpenIddict.Abstractions;
|
using OpenIddict.Abstractions;
|
||||||
@ -10,10 +11,14 @@ namespace MemberCenter.Web.Controllers.Admin;
|
|||||||
public class OAuthClientsController : Controller
|
public class OAuthClientsController : Controller
|
||||||
{
|
{
|
||||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||||
|
private readonly ITenantService _tenantService;
|
||||||
|
|
||||||
public OAuthClientsController(IOpenIddictApplicationManager applicationManager)
|
public OAuthClientsController(
|
||||||
|
IOpenIddictApplicationManager applicationManager,
|
||||||
|
ITenantService tenantService)
|
||||||
{
|
{
|
||||||
_applicationManager = applicationManager;
|
_applicationManager = applicationManager;
|
||||||
|
_tenantService = tenantService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
@ -22,12 +27,18 @@ public class OAuthClientsController : Controller
|
|||||||
var results = new List<object>();
|
var results = new List<object>();
|
||||||
await foreach (var application in _applicationManager.ListAsync())
|
await foreach (var application in _applicationManager.ListAsync())
|
||||||
{
|
{
|
||||||
|
var properties = await _applicationManager.GetPropertiesAsync(application);
|
||||||
|
var usage = properties.TryGetValue("usage", out var usageElement)
|
||||||
|
? usageElement.GetString()
|
||||||
|
: "tenant_api";
|
||||||
|
|
||||||
results.Add(new
|
results.Add(new
|
||||||
{
|
{
|
||||||
id = await _applicationManager.GetIdAsync(application),
|
id = await _applicationManager.GetIdAsync(application),
|
||||||
name = await _applicationManager.GetDisplayNameAsync(application),
|
name = await _applicationManager.GetDisplayNameAsync(application),
|
||||||
client_id = await _applicationManager.GetClientIdAsync(application),
|
client_id = await _applicationManager.GetClientIdAsync(application),
|
||||||
client_type = await _applicationManager.GetClientTypeAsync(application),
|
client_type = await _applicationManager.GetClientTypeAsync(application),
|
||||||
|
usage = usage,
|
||||||
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
|
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -36,16 +47,26 @@ public class OAuthClientsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("create")]
|
[HttpGet("create")]
|
||||||
public IActionResult Create()
|
public async Task<IActionResult> Create()
|
||||||
{
|
{
|
||||||
return View(new OAuthClientFormViewModel());
|
var tenants = await _tenantService.ListAsync();
|
||||||
|
return View(new OAuthClientFormViewModel
|
||||||
|
{
|
||||||
|
Tenants = tenants
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("create")]
|
[HttpPost("create")]
|
||||||
public async Task<IActionResult> Create(OAuthClientFormViewModel model)
|
public async Task<IActionResult> Create(OAuthClientFormViewModel model)
|
||||||
{
|
{
|
||||||
|
if (!IsValidUsage(model.Usage))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
|
model.Tenants = await _tenantService.ListAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +103,7 @@ public class OAuthClientsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
|
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
|
||||||
|
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
||||||
|
|
||||||
await _applicationManager.CreateAsync(descriptor);
|
await _applicationManager.CreateAsync(descriptor);
|
||||||
|
|
||||||
@ -106,21 +128,32 @@ public class OAuthClientsController : Controller
|
|||||||
var redirectUris = await _applicationManager.GetRedirectUrisAsync(app);
|
var redirectUris = await _applicationManager.GetRedirectUrisAsync(app);
|
||||||
var properties = await _applicationManager.GetPropertiesAsync(app);
|
var properties = await _applicationManager.GetPropertiesAsync(app);
|
||||||
var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty;
|
var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty;
|
||||||
|
var usage = properties.TryGetValue("usage", out var usageValue) ? usageValue.GetString() : "tenant_api";
|
||||||
|
|
||||||
|
var tenants = await _tenantService.ListAsync();
|
||||||
|
|
||||||
return View(new OAuthClientFormViewModel
|
return View(new OAuthClientFormViewModel
|
||||||
{
|
{
|
||||||
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty,
|
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty,
|
||||||
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
|
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
|
||||||
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
|
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
|
||||||
RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString()))
|
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
|
||||||
|
RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())),
|
||||||
|
Tenants = tenants
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("edit/{id}")]
|
[HttpPost("edit/{id}")]
|
||||||
public async Task<IActionResult> Edit(string id, OAuthClientFormViewModel model)
|
public async Task<IActionResult> Edit(string id, OAuthClientFormViewModel model)
|
||||||
{
|
{
|
||||||
|
if (!IsValidUsage(model.Usage))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
|
model.Tenants = await _tenantService.ListAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +174,7 @@ public class OAuthClientsController : Controller
|
|||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
|
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
|
||||||
|
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
||||||
|
|
||||||
await _applicationManager.UpdateAsync(app, descriptor);
|
await _applicationManager.UpdateAsync(app, descriptor);
|
||||||
return RedirectToAction("Index");
|
return RedirectToAction("Index");
|
||||||
@ -187,4 +221,10 @@ public class OAuthClientsController : Controller
|
|||||||
|
|
||||||
return RedirectToAction("Index");
|
return RedirectToAction("Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidUsage(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,12 @@ public sealed class OAuthClientFormViewModel
|
|||||||
[Required]
|
[Required]
|
||||||
public string ClientType { get; set; } = "public";
|
public string ClientType { get; set; } = "public";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Usage { get; set; } = "tenant_api";
|
||||||
|
|
||||||
public string RedirectUris { get; set; } = string.Empty;
|
public string RedirectUris { get; set; } = string.Empty;
|
||||||
|
public IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto> Tenants { get; set; }
|
||||||
|
= Array.Empty<MemberCenter.Application.Models.Admin.TenantDto>();
|
||||||
|
|
||||||
public string? ClientId { get; set; }
|
public string? ClientId { get; set; }
|
||||||
public string? ClientSecret { get; set; }
|
public string? ClientSecret { get; set; }
|
||||||
|
|||||||
@ -50,13 +50,15 @@ builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
|||||||
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
||||||
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
||||||
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
|
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
|
||||||
|
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||||
|
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||||
|
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
||||||
|
|
||||||
builder.Services.AddOpenIddict()
|
builder.Services.AddOpenIddict()
|
||||||
.AddCore(options =>
|
.AddCore(options =>
|
||||||
{
|
{
|
||||||
options.UseEntityFrameworkCore()
|
options.UseEntityFrameworkCore()
|
||||||
.UseDbContext<MemberCenterDbContext>()
|
.UseDbContext<MemberCenterDbContext>();
|
||||||
.ReplaceDefaultEntities<Guid>();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllersWithViews()
|
builder.Services.AddControllersWithViews()
|
||||||
|
|||||||
@ -3,7 +3,13 @@
|
|||||||
<h1>Create OAuth Client</h1>
|
<h1>Create OAuth Client</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label>Tenant Id</label>
|
<label>Tenant Id</label>
|
||||||
<input asp-for="TenantId" />
|
<select asp-for="TenantId">
|
||||||
|
<option value="">Select a tenant</option>
|
||||||
|
@foreach (var tenant in Model.Tenants)
|
||||||
|
{
|
||||||
|
<option value="@tenant.Id">@tenant.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
<span asp-validation-for="TenantId"></span>
|
<span asp-validation-for="TenantId"></span>
|
||||||
|
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
@ -11,9 +17,19 @@
|
|||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Client Type</label>
|
<label>Client Type</label>
|
||||||
<input asp-for="ClientType" />
|
<select asp-for="ClientType">
|
||||||
|
<option value="public">public</option>
|
||||||
|
<option value="confidential">confidential</option>
|
||||||
|
</select>
|
||||||
<span asp-validation-for="ClientType"></span>
|
<span asp-validation-for="ClientType"></span>
|
||||||
|
|
||||||
|
<label>Usage</label>
|
||||||
|
<select asp-for="Usage">
|
||||||
|
<option value="tenant_api">tenant_api</option>
|
||||||
|
<option value="webhook_outbound">webhook_outbound</option>
|
||||||
|
</select>
|
||||||
|
<span asp-validation-for="Usage"></span>
|
||||||
|
|
||||||
<label>Redirect URIs (comma-separated)</label>
|
<label>Redirect URIs (comma-separated)</label>
|
||||||
<input asp-for="RedirectUris" />
|
<input asp-for="RedirectUris" />
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,13 @@
|
|||||||
<h1>Edit OAuth Client</h1>
|
<h1>Edit OAuth Client</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label>Tenant Id</label>
|
<label>Tenant Id</label>
|
||||||
<input asp-for="TenantId" />
|
<select asp-for="TenantId">
|
||||||
|
<option value="">Select a tenant</option>
|
||||||
|
@foreach (var tenant in Model.Tenants)
|
||||||
|
{
|
||||||
|
<option value="@tenant.Id">@tenant.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
<span asp-validation-for="TenantId"></span>
|
<span asp-validation-for="TenantId"></span>
|
||||||
|
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
@ -11,9 +17,19 @@
|
|||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Client Type</label>
|
<label>Client Type</label>
|
||||||
<input asp-for="ClientType" />
|
<select asp-for="ClientType">
|
||||||
|
<option value="public">public</option>
|
||||||
|
<option value="confidential">confidential</option>
|
||||||
|
</select>
|
||||||
<span asp-validation-for="ClientType"></span>
|
<span asp-validation-for="ClientType"></span>
|
||||||
|
|
||||||
|
<label>Usage</label>
|
||||||
|
<select asp-for="Usage">
|
||||||
|
<option value="tenant_api">tenant_api</option>
|
||||||
|
<option value="webhook_outbound">webhook_outbound</option>
|
||||||
|
</select>
|
||||||
|
<span asp-validation-for="Usage"></span>
|
||||||
|
|
||||||
<label>Redirect URIs (comma-separated)</label>
|
<label>Redirect URIs (comma-separated)</label>
|
||||||
<input asp-for="RedirectUris" />
|
<input asp-for="RedirectUris" />
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Client Id</th><th>Type</th><th></th></tr>
|
<tr><th>Name</th><th>Client Id</th><th>Type</th><th>Usage</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in Model)
|
@foreach (var item in Model)
|
||||||
@ -34,11 +34,13 @@
|
|||||||
var name = (string)item.GetType().GetProperty("name")!.GetValue(item)!;
|
var name = (string)item.GetType().GetProperty("name")!.GetValue(item)!;
|
||||||
var clientId = (string)item.GetType().GetProperty("client_id")!.GetValue(item)!;
|
var clientId = (string)item.GetType().GetProperty("client_id")!.GetValue(item)!;
|
||||||
var clientType = (string)item.GetType().GetProperty("client_type")!.GetValue(item)!;
|
var clientType = (string)item.GetType().GetProperty("client_type")!.GetValue(item)!;
|
||||||
|
var usage = (string)item.GetType().GetProperty("usage")!.GetValue(item)!;
|
||||||
var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!;
|
var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!;
|
||||||
<tr>
|
<tr>
|
||||||
<td>@name</td>
|
<td>@name</td>
|
||||||
<td>@clientId</td>
|
<td>@clientId</td>
|
||||||
<td>@clientType</td>
|
<td>@clientType</td>
|
||||||
|
<td>@usage</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/admin/oauth-clients/edit/@id">Edit</a>
|
<a href="/admin/oauth-clients/edit/@id">Edit</a>
|
||||||
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase))
|
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@ -4,13 +4,14 @@
|
|||||||
<p><a href="/admin/tenants/create">Create</a></p>
|
<p><a href="/admin/tenants/create">Create</a></p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Domains</th><th>Status</th><th></th></tr>
|
<tr><th>Name</th><th>Tenant Id</th><th>Domains</th><th>Status</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var tenant in Model)
|
@foreach (var tenant in Model)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@tenant.Name</td>
|
<td>@tenant.Name</td>
|
||||||
|
<td><code>@tenant.Id</code></td>
|
||||||
<td>@string.Join(",", tenant.Domains)</td>
|
<td>@string.Join(",", tenant.Domains)</td>
|
||||||
<td>@tenant.Status</td>
|
<td>@tenant.Status</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user