feat: Enhance Send Engine integration with webhook publishing and configuration options

This commit is contained in:
warrenchen 2026-02-17 17:55:54 +09:00
parent 8c6e3c550f
commit ae6edae39c
21 changed files with 377 additions and 25 deletions

View File

@ -1,2 +1,4 @@
ASPNETCORE_ENVIRONMENT=Development
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
View 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.

View File

@ -43,7 +43,7 @@
- roles / user_roles (Identity)
- id, name, created_at
- 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
- id, application_id, status, subject, type, scopes
- OpenIddictTokens
@ -100,7 +100,8 @@
### 6.6 Send Engine 事件同步Member Center → Send Engine
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`
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 退信/黑名單回寫(選用)
1) Send Engine 判定黑名單(例如 hard bounce / complaint
@ -138,6 +139,7 @@
### Auth / Scope
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api``webhook_outbound`(禁止混用)
- 新增 scope`newsletter:list.read``newsletter:events.read`
- 新增 scope`newsletter:events.write`

View File

@ -38,7 +38,10 @@
## F-10 Send Engine 事件同步Member Center → Send Engine
- [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 更新名單快照
## F-11 黑名單回寫Send Engine → Member Center

View File

@ -40,8 +40,16 @@
```
ASPNETCORE_ENVIRONMENT=Development
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`
用途:首次安裝(含 migrations + seed + superuser

View File

@ -53,6 +53,7 @@ End User
租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。
- 向 Member Center 申請 OAuth Client
- OAuth Client 必須使用 `usage=tenant_api`(不可使用 `webhook_outbound`
- scope 依用途最小化
建議 scopes

View File

@ -41,6 +41,35 @@
- 與訂閱者資料preferences、unsubscribe token相關的查詢與寫入一律必須帶 `list_id + email` 做租戶邊界約束。
- 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。
## Webhook AuthMember 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
- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用)

View File

@ -11,7 +11,7 @@
### 管理者端
- 租戶管理Tenant CRUD
- OAuth Client 管理redirect_uris / scopes / client_id / client_secret
- OAuth Client 管理(usage / redirect_uris / scopes / client_id / client_secret
- 電子報清單管理Lists CRUD
- 訂閱查詢 / 匯出
- 審計紀錄查詢
@ -48,7 +48,7 @@
### 管理者端(統一 UI
- 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-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs`

View File

@ -466,8 +466,25 @@ paths:
/webhooks/subscriptions:
post:
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: []
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:
required: true
content:
@ -481,8 +498,25 @@ paths:
/webhooks/lists/full-sync:
post:
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: []
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:
required: true
content:
@ -871,5 +905,6 @@ components:
id: { type: string }
tenant_id: { type: string }
name: { type: string }
usage: { type: string, enum: [tenant_api, webhook_outbound] }
redirect_uris: { type: array, items: { type: string } }
client_type: { type: string, enum: [public, confidential] }

View File

@ -44,8 +44,7 @@ builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<MemberCenterDbContext>()
.ReplaceDefaultEntities<Guid>();
.UseDbContext<MemberCenterDbContext>();
})
.AddServer(options =>
{
@ -90,6 +89,9 @@ builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ITenantService, TenantService>();
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();

View File

@ -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);
}

View File

@ -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);
}

View File

@ -4,6 +4,7 @@ using MemberCenter.Domain.Constants;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@ -19,11 +20,19 @@ public sealed class NewsletterService : INewsletterService
private readonly MemberCenterDbContext _dbContext;
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;
_emailBlacklist = emailBlacklist;
_webhookPublisher = webhookPublisher;
_logger = logger;
}
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;
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)
@ -131,7 +142,9 @@ public sealed class NewsletterService : INewsletterService
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
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)
@ -198,7 +211,9 @@ public sealed class NewsletterService : INewsletterService
subscription.Preferences = ToJsonDocument(preferences);
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)
@ -253,4 +268,22 @@ public sealed class NewsletterService : INewsletterService
.Include(t => t.Subscription)
.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);
}
}

View File

@ -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();
}
}

View File

@ -1,4 +1,5 @@
using MemberCenter.Web.Models.Admin;
using MemberCenter.Application.Abstractions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
@ -10,10 +11,14 @@ namespace MemberCenter.Web.Controllers.Admin;
public class OAuthClientsController : Controller
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly ITenantService _tenantService;
public OAuthClientsController(IOpenIddictApplicationManager applicationManager)
public OAuthClientsController(
IOpenIddictApplicationManager applicationManager,
ITenantService tenantService)
{
_applicationManager = applicationManager;
_tenantService = tenantService;
}
[HttpGet("")]
@ -22,12 +27,18 @@ public class OAuthClientsController : Controller
var results = new List<object>();
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
{
id = await _applicationManager.GetIdAsync(application),
name = await _applicationManager.GetDisplayNameAsync(application),
client_id = await _applicationManager.GetClientIdAsync(application),
client_type = await _applicationManager.GetClientTypeAsync(application),
usage = usage,
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
});
}
@ -36,16 +47,26 @@ public class OAuthClientsController : Controller
}
[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")]
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)
{
model.Tenants = await _tenantService.ListAsync();
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["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.CreateAsync(descriptor);
@ -106,21 +128,32 @@ public class OAuthClientsController : Controller
var redirectUris = await _applicationManager.GetRedirectUrisAsync(app);
var properties = await _applicationManager.GetPropertiesAsync(app);
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
{
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty,
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
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}")]
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)
{
model.Tenants = await _tenantService.ListAsync();
return View(model);
}
@ -141,6 +174,7 @@ public class OAuthClientsController : Controller
descriptor.RedirectUris.Add(new Uri(uri));
}
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);
return RedirectToAction("Index");
@ -187,4 +221,10 @@ public class OAuthClientsController : Controller
return RedirectToAction("Index");
}
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
}
}

View File

@ -13,7 +13,12 @@ public sealed class OAuthClientFormViewModel
[Required]
public string ClientType { get; set; } = "public";
[Required]
public string Usage { get; set; } = "tenant_api";
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? ClientSecret { get; set; }

View File

@ -50,13 +50,15 @@ builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
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()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<MemberCenterDbContext>()
.ReplaceDefaultEntities<Guid>();
.UseDbContext<MemberCenterDbContext>();
});
builder.Services.AddControllersWithViews()

View File

@ -3,7 +3,13 @@
<h1>Create OAuth Client</h1>
<form method="post">
<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>
<label>Name</label>
@ -11,9 +17,19 @@
<span asp-validation-for="Name"></span>
<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>
<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>
<input asp-for="RedirectUris" />

View File

@ -3,7 +3,13 @@
<h1>Edit OAuth Client</h1>
<form method="post">
<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>
<label>Name</label>
@ -11,9 +17,19 @@
<span asp-validation-for="Name"></span>
<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>
<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>
<input asp-for="RedirectUris" />

View File

@ -26,7 +26,7 @@
}
<table>
<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>
<tbody>
@foreach (var item in Model)
@ -34,11 +34,13 @@
var name = (string)item.GetType().GetProperty("name")!.GetValue(item)!;
var clientId = (string)item.GetType().GetProperty("client_id")!.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)!;
<tr>
<td>@name</td>
<td>@clientId</td>
<td>@clientType</td>
<td>@usage</td>
<td>
<a href="/admin/oauth-clients/edit/@id">Edit</a>
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase))

View File

@ -4,13 +4,14 @@
<p><a href="/admin/tenants/create">Create</a></p>
<table>
<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>
<tbody>
@foreach (var tenant in Model)
{
<tr>
<td>@tenant.Name</td>
<td><code>@tenant.Id</code></td>
<td>@string.Join(",", tenant.Domains)</td>
<td>@tenant.Status</td>
<td>