Add admin area controllers and views for managing OAuth clients, security settings, subscriptions, and tenants

- Implemented OAuthClientsController for CRUD operations on OAuth clients.
- Added SecurityController to manage security settings.
- Created SubscriptionsController for handling subscriptions with export functionality.
- Developed TenantsController for tenant management including create, edit, and delete operations.
- Added views for each controller to facilitate user interaction.
- Introduced layout and shared views for consistent admin UI.
- Implemented model classes for handling data in views.
- Added validation and error handling in forms.
This commit is contained in:
warrenchen 2026-04-01 17:40:45 +09:00
parent 293303c989
commit 75e235b8e3
49 changed files with 1149 additions and 132 deletions

View File

@ -50,7 +50,7 @@
member_center/
├── src/
│ ├── MemberCenter.Api/ # REST APIOAuth/OIDC、訂閱、管理 API
│ ├── MemberCenter.Web/ # MVC Web UI會員與後台頁面)
│ ├── MemberCenter.Web/ # MVC Web UIclient-first 會員介面 + Areas/Admin 管理介面)
│ ├── MemberCenter.Installer/ # 安裝與初始化 CLImigrate/init/admin
│ ├── MemberCenter.Application/ # 應用層介面與 DTO
│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作
@ -66,3 +66,11 @@ member_center/
- 事件系統選擇Kafka/RabbitMQ/SNS+SQS
- 取消訂閱的 UI 形式(純一鍵或提供偏好)
- GDPR/CCPA 資料匯出與刪除是否納入第一期
## 目前 UI / Auth 狀態
- `MemberCenter.Web` 採單一登入入口client 與 admin 共用同一套帳號。
- `admin` role 使用者在 client 主介面中可看到 `Admin` 功能群組。
- `/admin/*` 已移至 `Areas/Admin`,未登入或非 admin 存取時一律回 `404`
- 本地註冊維持 `UserName = Email`,新帳號預設為未認證但可登入。
- 已支援 Google external login / register 與同 email auto-link。
- 註冊或 external login 建立帳號後,若已存在同 email 訂閱資料,會自動補 `newsletter_subscriptions.user_id`、寫入 audit log並發送 `subscription.linked_to_user` 事件。

View File

@ -0,0 +1,260 @@
# Admin / Client UI 拆分工作計劃
## 目標
- 在同一個 `MemberCenter.Web` 專案中,將會員端與管理端 UI 明確分區。
- 保持單一登入入口與單一帳號系統。
- 讓具有 `admin` 權限的帳號同時可使用會員功能與管理功能。
- 將目前偏向後台的共用介面,調整為 client-first 的會員中心體驗。
- 補齊會員註冊、Google 第三方登入與訂閱資料銜接流程。
## 已確認決策
### 帳號與登入
- `Admin``Client` 共用同一個登入入口。
- `Admin` 帳號同時也是會員帳號,可使用會員介面功能。
- 不拆成兩套認證系統,不建立獨立 admin login。
### UI 與導覽
- 會員端為主介面。
- 功能選單採分組方式呈現。
- 一般會員只看到會員功能。
- 具有 `admin` role 的帳號,額外看到 `Admin` 功能分類。
- `Admin` 分類展開後顯示管理功能連結。
- 目前先以 `admin` role 做整包顯示,不先做細權限切分。
### 未授權存取
- 非 admin 使用者存取 `/admin/*` 時,回應 `404`
- 不使用 `403` 頁面暴露後台存在。
## 目標範圍
### 本次要做
- 調整 `MemberCenter.Web` 路由與結構,將管理端移入 `Areas/Admin`
- 將現有共用 layout 改為 client-first 導覽。
- 將 admin 功能從全站共用導覽中抽離,改成 role-based 顯示。
- 建立 admin 路由未授權時回 `404` 的處理方式。
- 補上會員註冊、第三方登入與訂閱綁定的工作規劃。
### 本次不做
- 細粒度權限模型,例如依功能模組拆 `tenant.read``audit.read`
- 獨立的 `AdminWeb` / `ClientWeb` 專案拆分。
- 大幅重做視覺設計。
- API 權限模型重構。
- 註冊確認信寄送實作。
## 實作策略
### 策略原則
- 先切 UI 邊界,再保留既有 Identity 與 role policy。
- 先做低風險結構重整,不同時引入細權限與大幅 UI redesign。
- 保持既有 URL 慣例,避免不必要的 route breakage。
### 預期結構
```text
src/MemberCenter.Web/
├── Areas/
│ └── Admin/
│ ├── Controllers/
│ └── Views/
├── Controllers/ # client only
├── Views/
│ ├── Shared/
│ │ ├── _Layout.cshtml # client-first layout
│ │ └── ...
│ └── ...
```
## 分階段計劃
### Phase 0: 會員註冊與帳號銜接規格補齊
狀態:已完成
目標:先將會員建立、第三方登入與訂閱資料綁定的規則固定,避免後續 UI 與 auth 重構互相衝突。
#### 需求規則
- 會員帳號以 `email` 為主要識別。
- `UserName` 強制等於 `Email`
- 不提供獨立 username。
- 本地註冊完成後,帳號標記為未認證。
- 本階段先不寄送確認信。
- 未認證帳號仍可登入。
- 後續功能完整後,未認證帳號將可被限制部分功能;本階段先保留此狀態與擴充空間。
- 支援 Google 作為第一個第三方登入/註冊 provider。
- Google 第一次登入時,若系統已存在相同 email 的本地帳號,直接 auto-link。
- Google 回傳 email 即使未驗證,仍允許建立帳號或連接既有帳號。
- 使用者若先以本地帳號註冊,之後再以 Google 同 email 登入,應連接到同一個帳號,不建立第二個 user。
- 註冊成功後,若系統中已有相同 email 的訂閱資料,需將相關 `newsletter_subscriptions.user_id` 補上。
- 訂閱綁定時必須保留既有訂閱狀態與偏好,不可覆蓋。
- 訂閱綁定完成後需補一筆 audit log。
- 後續若導入事件,保留發送 `subscription.linked_to_user` 的擴充空間。
#### 子工作
- 已完成:定義本地註冊後的帳號狀態與登入規則。
- 已完成:定義 Google external login / register / auto-link 流程。
- 已完成:定義訂閱資料綁定與 audit log 寫入時機。
- 已完成:將上述規則同步反映到目前的 Web / API 實作階段。
- 已完成:`subscription.linked_to_user` 事件發送。
- 註記:未認證帳號的功能限制屬後續能力擴充,不阻擋本 phase 完成。
- 註記Google 實際整合驗證仍需提供 Google OAuth 設定,屬外部驗證條件,不阻擋本 phase 完成。
完成條件:
- 註冊、Google 登入、同 email 帳號連接、訂閱綁定規則均有明確定義。
- 後續 Phase 1 之後的 UI 與 auth 重構可直接依規則實作。
### Phase 1: Route 與目錄切分
狀態:已完成
目標:先建立清楚的 UI 邊界。
- 將現有 `Controllers/Admin/*` 移入 `Areas/Admin/Controllers/*`
- 將現有 `Views/Admin/*` 移入 `Areas/Admin/Views/*`
- 調整 route 設定,讓 `/admin/*` 由 area route 處理。
- 確認既有 admin URL 可維持不變。
完成條件:
- 所有 admin 頁面由 `Areas/Admin` 提供。
- 會員端 controller 不再與 admin controller 混在同一層。
### Phase 2: Layout 與導覽切分
狀態:已完成
目標:把 UI 改成 client-first不再全站露出後台功能。
- 重構共用 layout移除固定顯示的 admin 連結。
- 建立 client-first 功能選單。
- 若使用者具 `admin` role顯示 `Admin` 功能分類。
- `Admin` 分類底下先列出既有管理功能:
- Tenants
- Newsletter Lists
- Subscriptions
- OAuth Clients
- Audit Logs
- Security
- Blacklist
完成條件:
- 一般會員不會在主選單看到 admin 連結。
- admin 使用者可從同一套主介面展開進入管理功能。
### Phase 3: Admin 畫面容器整理
狀態:已完成
目標:讓進入 admin 區後有明確上下文。
- 規劃 admin area 是否使用獨立 layout。
- 若使用獨立 layout保留回會員區入口。
- 若先共用 layout至少在 admin 頁面標示目前位於管理區。
建議:
- 第一版可先採用共用主殼 + admin 區塊標示。
- 若後續 admin 功能持續增長,再抽 `_AdminLayout`
目前進度:
- 已完成:獨立 `Admin` area layout。
- 已完成:保留回會員區入口。
- 已完成admin shell 基礎結構整理top bar、side nav、active state、區域標示
- 已完成:補上可替換的基礎樣式 hooks避免後續設計重做時需要拆 route 或 view 結構。
- 註記:後續若需進一步整理 admin/client 的整體體驗、內容層級、表格與表單版型,視為 UI/UX refinement不阻擋本 phase 完成。
完成條件:
- 使用者進入 admin 頁面時,有清楚的區域辨識。
### Phase 4: 未授權存取改為 404
狀態:已完成
目標:保留授權檢查,同時隱藏 admin surface。
- 保留 `[Authorize(Policy = "Admin")]`
- 增加 admin 未授權時的統一處理,避免顯示預設 `403`
- 確認未登入與已登入但非 admin 的行為符合預期。
已定案:
- 未登入進 `/admin/*`:直接回 `404`
- 已登入但非 admin 進 `/admin/*`:直接回 `404`
目前進度:
- 已完成:保留 `[Authorize(Policy = "Admin")]`
- 已完成admin 未授權時不顯示預設 `403`,改為 `404`
- 已完成:目前實作上,未登入與非 admin 存取 `/admin/*` 均回 `404`
- 已完成:將「未登入也回 `404`」正式定案並同步到工作計劃。
### Phase 5: 驗證與文件更新
狀態:已完成
目標:確保重構後行為可驗證、文件一致。
- 驗證會員端主要頁面仍可正常使用。
- 驗證 admin 帳號可以:
- 使用會員功能
- 看見 `Admin` 選單
- 進入 admin 各頁
- 驗證非 admin 帳號無法看見 admin 選單,且直接進 admin URL 會得到 `404`
- 更新 README / UI 文件中的 web 結構描述
目前進度:
- 已完成:`dotnet build MemberCenter.sln -m:1`
- 已完成:會員端主要頁面可用性驗證(首頁 / login / register
- 已完成admin 帳號操作驗證(可登入、可進 member profile、可進 admin route
- 已完成:一般會員登入驗證(可登入、可進 profile、首頁不顯示 admin 群組)。
- 已完成:非 admin / 未登入情境驗證(匿名打 `/admin/*``404`)。
- 已完成計劃文件、README、UI / Flow / Use Case / Design / OpenAPI 相關文件更新。
- 註記Google 真實 round-trip 驗證需提供 Google OAuth 設定,屬外部條件,不阻擋本 phase 完成。
## 影響檔案預估
- `src/MemberCenter.Web/Program.cs`
- `src/MemberCenter.Web/Views/Shared/_Layout.cshtml`
- `src/MemberCenter.Web/Controllers/Admin/*`
- `src/MemberCenter.Web/Views/Admin/*`
- 新增 `src/MemberCenter.Web/Areas/Admin/...`
- 視需要更新 `docs/UI.md`
## 風險與注意事項
- Area 導入後view 路徑與 route mapping 容易有小錯誤,需要逐頁驗證。
- 若直接共用同一個 layout需避免 client 與 admin 的語意混亂。
- `404` 偽裝策略要搭配真正的 authorization不能只靠 route 隱藏。
- 若未登入也直接回 `404`,可能會讓合法 admin 使用者失去登入引導;這點需明確決策。
## 建議執行順序
1. 先完成 Phase 0確認註冊、Google 登入與訂閱綁定規則。
2. 再完成 Phase 1做純結構重整。
3. 接著做 Phase 2修正選單與角色顯示。
4. 然後決定 Phase 3 要共用 layout 還是抽 admin layout。
5. 再做 Phase 4補齊 `404` 授權行為。
6. 最後做 Phase 5 的驗證與文件更新。
## 本次文件用途
這份計劃作為後續逐步實作的工作底稿。後續每一步都應以「單一階段可驗證完成」為原則,避免一次改太多導致 routing、授權與 UI 問題混在一起。
## 目前總結
- 已完成Phase 0、Phase 1、Phase 2、Phase 3、Phase 4、Phase 5
- 部分完成Phase 5

View File

@ -28,7 +28,7 @@
- 站點後台:可管理站點資訊、訂閱清單、會員基本資料
## 4. 核心模組
- Identity Service註冊、登入、密碼重設、Email 驗證
- Identity Service註冊、登入、修改密碼、密碼重設、Email 驗證
- OAuth2/OIDC Service授權流程、token 發放、ID Token
- Subscription Service訂閱/退訂/偏好管理
- Admin Console租戶與清單管理
@ -124,6 +124,13 @@
3) 將 `user_id` 補上並保留偏好
4) 可選:發出事件 `subscription.linked_to_user`
### 6.8 已登入修改密碼
1) 使用者登入會員中心
2) 進入 change password 頁面
3) 提交 `current_password + new_password`
4) 系統驗證目前密碼正確後更新 password hash
5) 更新成功後刷新目前 session
## 7. API 介面(草案)
- GET `/oauth/authorize`
- POST `/oauth/token`
@ -167,8 +174,8 @@
## 7.2 尚未完成(待辦)
- `POST /webhooks/lists/full-sync`Member Center 端尚未發送此事件(僅保留契約)
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)尚未在註冊流程落地
- `subscription.linked_to_user` 事件尚未發送
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)已在註冊 / external login 流程落地
- `subscription.linked_to_user` 事件發送
- 安全設定頁access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足

View File

@ -25,6 +25,14 @@
- [UI] 會員中心頁提交 email 並發送重設信
- [API/UI] 使用 token 進入重設密碼頁
註記:目前 Web UI 已實作 forgot/reset 流程,但尚未串接 email 發送;開發階段會直接顯示 reset token 與 reset 連結。
## F-03b 已登入修改密碼
- [UI] 使用者登入後進入 `/account/changepassword`
- [UI] 輸入目前密碼與新密碼
- [UI] 會員中心驗證目前密碼後更新密碼
- [UI] 更新成功後刷新登入狀態
## F-04 訂閱電子報(未登入)
- [API] 站點送出 `POST /newsletter/subscribe`
- [API] 會員中心建立 pending 訂閱並發送驗證信
@ -81,5 +89,3 @@
## F-09 訂閱與會員綁定
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
- [API] 發送事件 `subscription.linked_to_user`
註記:此流程目前尚未在程式中落地(屬待辦)。

View File

@ -2,7 +2,7 @@
## 會員中心(統一 UI
### 會員端
- 註冊 / 登入 / 忘記密碼
- 註冊 / 登入 / 忘記密碼 / 修改密碼
- Email 驗證
- 個人資料Email 為主)
- 訂閱管理(清單與偏好)
@ -19,7 +19,7 @@
## 各站自建 UIAPI
### 會員端
- 登入 / 註冊 / 忘記密碼
- 登入 / 註冊 / 忘記密碼 / 修改密碼
- Email 驗證頁(可自建或導回會員中心)
- 訂閱表單(未登入)
- 訂閱偏好管理(登入後)
@ -33,6 +33,9 @@
- 會員中心 UI 為統一入口(少數情境)
- 其餘皆走 API 與各站自建 UI
- 會員中心 UI 不承擔行銷內容或寄送
- `MemberCenter.Web` 採 client-first 介面admin 功能以角色判斷後顯示於同一登入入口內
- `/admin/*``Areas/Admin` 提供獨立管理區殼層
- 非 admin 或未登入存取 `/admin/*` 時,回 `404`
## UI 路徑對應Use Cases
### 會員端(統一 UI
@ -40,6 +43,7 @@
- UC-02 登入: `/account/login`
- UC-03 登出: `POST /account/logout`
- UC-04 忘記密碼 / 重設密碼: `/account/forgotpassword`, `/account/resetpassword`
- UC-04.1 已登入修改密碼: `/account/changepassword`
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
- UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...`
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`

View File

@ -9,6 +9,7 @@
- UC-02 登入(取得 token [API/UI]
- UC-03 登出 [API/UI]
- UC-04 忘記密碼 / 重設密碼 [API/UI]
- UC-04.1 已登入修改密碼 [UI]
- UC-05 Email 驗證 [API/UI]
- UC-06 訂閱電子報(未登入) [API]
- UC-07 訂閱確認double opt-in [UI]
@ -31,4 +32,4 @@
## 實作狀態2026-02
- 已完成UC-17、UC-18以 webhook 事件發送)
- 完成UC-19註冊後自動綁定 `user_id``subscription.linked_to_user` 事件)
- 完成UC-19註冊後自動綁定 `user_id``subscription.linked_to_user` 事件)

View File

@ -1002,7 +1002,7 @@ components:
required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at]
properties:
event_id: { type: string }
event_type: { type: string, enum: [subscription.activated, subscription.unsubscribed, preferences.updated] }
event_type: { type: string, enum: [subscription.activated, subscription.unsubscribed, preferences.updated, subscription.linked_to_user] }
tenant_id: { type: string }
list_id: { type: string }
subscriber:

View File

@ -1,4 +1,5 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@ -10,11 +11,16 @@ namespace MemberCenter.Api.Controllers;
[Route("auth")]
public class AuthController : ControllerBase
{
private readonly IAccountProvisioningService _accountProvisioningService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AuthController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
public AuthController(
IAccountProvisioningService accountProvisioningService,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_accountProvisioningService = accountProvisioningService;
_userManager = userManager;
_signInManager = signInManager;
}
@ -22,26 +28,18 @@ public class AuthController : ControllerBase
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = request.Email,
Email = request.Email,
EmailConfirmed = false
};
var result = await _userManager.CreateAsync(user, request.Password);
var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
if (!result.Succeeded)
{
return BadRequest(result.Errors.Select(e => e.Description));
return BadRequest(result.Errors);
}
return Ok(new
{
id = user.Id,
email = user.Email,
email_verified = user.EmailConfirmed,
created_at = user.CreatedAt
id = result.UserId,
email = result.Email,
email_verified = result.EmailConfirmed,
linked_subscriptions = result.LinkedSubscriptionsCount
});
}

View File

@ -127,6 +127,7 @@ builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();

View File

@ -0,0 +1,9 @@
using MemberCenter.Application.Models.Account;
namespace MemberCenter.Application.Abstractions;
public interface IAccountProvisioningService
{
Task<AccountProvisioningResult> RegisterLocalAsync(string email, string password);
Task<AccountProvisioningResult> ProvisionExternalLoginAsync(string loginProvider, string providerKey, string? email, bool emailVerified);
}

View File

@ -0,0 +1,11 @@
namespace MemberCenter.Application.Models.Account;
public sealed record AccountProvisioningResult(
bool Succeeded,
Guid? UserId,
string? Email,
bool EmailConfirmed,
bool CreatedUser,
bool LinkedExternalLogin,
int LinkedSubscriptionsCount,
IReadOnlyList<string> Errors);

View File

@ -0,0 +1,244 @@
using System.Text.Json;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Account;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MemberCenter.Application.Models.Newsletter;
namespace MemberCenter.Infrastructure.Services;
public sealed class AccountProvisioningService : IAccountProvisioningService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly MemberCenterDbContext _dbContext;
private readonly ISendEngineWebhookPublisher _webhookPublisher;
private readonly ILogger<AccountProvisioningService> _logger;
public AccountProvisioningService(
UserManager<ApplicationUser> userManager,
MemberCenterDbContext dbContext,
ISendEngineWebhookPublisher webhookPublisher,
ILogger<AccountProvisioningService> logger)
{
_userManager = userManager;
_dbContext = dbContext;
_webhookPublisher = webhookPublisher;
_logger = logger;
}
public async Task<AccountProvisioningResult> RegisterLocalAsync(string email, string password)
{
var normalizedEmail = NormalizeEmail(email);
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = normalizedEmail,
Email = normalizedEmail,
EmailConfirmed = false
};
var result = await _userManager.CreateAsync(user, password);
if (!result.Succeeded)
{
return Failed(result);
}
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(user, "local_registration", new
{
email = normalizedEmail
});
return new AccountProvisioningResult(
true,
user.Id,
user.Email,
user.EmailConfirmed,
true,
false,
linkedSubscriptionsCount,
Array.Empty<string>());
}
public async Task<AccountProvisioningResult> ProvisionExternalLoginAsync(
string loginProvider,
string providerKey,
string? email,
bool emailVerified)
{
var existingByLogin = await _userManager.FindByLoginAsync(loginProvider, providerKey);
if (existingByLogin is not null)
{
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(existingByLogin, "external_login_reuse", new
{
login_provider = loginProvider
});
return new AccountProvisioningResult(
true,
existingByLogin.Id,
existingByLogin.Email,
existingByLogin.EmailConfirmed,
false,
false,
linkedSubscriptionsCount,
Array.Empty<string>());
}
if (string.IsNullOrWhiteSpace(email))
{
return new AccountProvisioningResult(
false,
null,
null,
false,
false,
false,
0,
new[] { "External login did not provide an email address." });
}
var normalizedEmail = NormalizeEmail(email);
var user = await _userManager.FindByEmailAsync(normalizedEmail);
var createdUser = false;
if (user is null)
{
user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = normalizedEmail,
Email = normalizedEmail,
EmailConfirmed = emailVerified
};
var createResult = await _userManager.CreateAsync(user);
if (!createResult.Succeeded)
{
return Failed(createResult);
}
createdUser = true;
}
else if (emailVerified && !user.EmailConfirmed)
{
user.EmailConfirmed = true;
await _userManager.UpdateAsync(user);
}
var addLoginResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(loginProvider, providerKey, loginProvider));
if (!addLoginResult.Succeeded
&& addLoginResult.Errors.All(x => x.Code != nameof(IdentityErrorDescriber.LoginAlreadyAssociated)))
{
return Failed(addLoginResult);
}
var linkedSubscriptions = await LinkSubscriptionsAndAuditAsync(user, "external_login_linked", new
{
login_provider = loginProvider,
created_user = createdUser
});
return new AccountProvisioningResult(
true,
user.Id,
user.Email,
user.EmailConfirmed,
createdUser,
true,
linkedSubscriptions,
Array.Empty<string>());
}
private async Task<int> LinkSubscriptionsAndAuditAsync(ApplicationUser user, string source, object payload)
{
var email = NormalizeEmail(user.Email ?? user.UserName ?? string.Empty);
var subscriptions = await _dbContext.NewsletterSubscriptions
.Where(x => x.UserId == null && x.Email.ToLower() == email)
.ToListAsync();
if (subscriptions.Count == 0)
{
return 0;
}
foreach (var subscription in subscriptions)
{
subscription.UserId = user.Id;
}
var linkedSubscriptions = subscriptions
.Select(subscription => new SubscriptionDto(
subscription.Id,
subscription.ListId,
subscription.Email,
subscription.Status,
subscription.Preferences.RootElement.Clone(),
subscription.CreatedAt))
.ToList();
_dbContext.AuditLogs.Add(new AuditLog
{
Id = Guid.NewGuid(),
ActorType = "system",
ActorId = user.Id,
Action = "subscription.linked_to_user",
Payload = JsonDocument.Parse(JsonSerializer.Serialize(new
{
user_id = user.Id,
email,
linked_subscriptions = subscriptions.Count,
source,
metadata = payload
}))
});
await _dbContext.SaveChangesAsync();
await PublishLinkedSubscriptionEventsAsync(linkedSubscriptions);
return subscriptions.Count;
}
private async Task PublishLinkedSubscriptionEventsAsync(IReadOnlyList<SubscriptionDto> subscriptions)
{
if (subscriptions.Count == 0)
{
return;
}
var listIds = subscriptions.Select(x => x.ListId).Distinct().ToList();
var tenantMap = await _dbContext.NewsletterLists
.Where(x => listIds.Contains(x.Id))
.ToDictionaryAsync(x => x.Id, x => x.TenantId);
foreach (var subscription in subscriptions)
{
if (!tenantMap.TryGetValue(subscription.ListId, out var tenantId) || tenantId == Guid.Empty)
{
_logger.LogWarning(
"Skip linked subscription event because list {ListId} has no tenant mapping",
subscription.ListId);
continue;
}
await _webhookPublisher.PublishSubscriptionEventAsync("subscription.linked_to_user", tenantId, subscription);
}
}
private static AccountProvisioningResult Failed(IdentityResult result) =>
new(
false,
null,
null,
false,
false,
false,
0,
result.Errors.Select(x => x.Description).ToArray());
private static string NormalizeEmail(string email) =>
email.Trim().ToLowerInvariant();
}

View File

@ -2,8 +2,9 @@ using MemberCenter.Application.Abstractions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/audit-logs")]
public class AuditLogsController : Controller

View File

@ -3,8 +3,9 @@ using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/blacklist")]
public class BlacklistController : Controller

View File

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin")]
public sealed class HomeController : Controller
{
[HttpGet("")]
public IActionResult Index()
{
return View();
}
}

View File

@ -3,8 +3,9 @@ using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/newsletter-lists")]
public class NewsletterListsController : Controller

View File

@ -1,11 +1,12 @@
using MemberCenter.Web.Models.Admin;
using MemberCenter.Application.Abstractions;
using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
namespace MemberCenter.Web.Controllers.Admin;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/oauth-clients")]
public class OAuthClientsController : Controller
@ -38,7 +39,7 @@ public class OAuthClientsController : Controller
name = await _applicationManager.GetDisplayNameAsync(application),
client_id = await _applicationManager.GetClientIdAsync(application),
client_type = await _applicationManager.GetClientTypeAsync(application),
usage = usage,
usage,
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
});
}
@ -80,6 +81,7 @@ public class OAuthClientsController : Controller
{
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
}
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
{
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
@ -111,6 +113,7 @@ public class OAuthClientsController : Controller
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
}
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.CreateAsync(descriptor);
@ -175,6 +178,7 @@ public class OAuthClientsController : Controller
{
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
}
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
{
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
@ -203,6 +207,7 @@ public class OAuthClientsController : Controller
{
descriptor.RedirectUris.Add(new Uri(uri));
}
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
@ -211,6 +216,7 @@ public class OAuthClientsController : Controller
{
descriptor.Properties.Remove("tenant_id");
}
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.UpdateAsync(app, descriptor);
@ -259,54 +265,36 @@ public class OAuthClientsController : Controller
return RedirectToAction("Index");
}
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool IsValidUsage(string usage) =>
usage is "tenant_api" or "send_api" or "webhook_outbound" or "platform_service";
private static bool IsTenantOptionalUsage(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTenantOptionalUsage(string usage) =>
usage == "platform_service";
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase);
}
private static bool UsesAuthorizationCodeFlow(string usage) =>
usage == "webhook_outbound";
private static bool UsesAuthorizationCodeFlow(string usage)
{
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresClientCredentials(string usage) =>
usage is "tenant_api" or "send_api" or "platform_service";
private static List<string> NormalizeRedirectUris(string redirectUrisText, out string? error)
private static List<string> NormalizeRedirectUris(string? value, out string? error)
{
error = null;
if (string.IsNullOrWhiteSpace(redirectUrisText))
{
return [];
}
var values = redirectUrisText
var items = (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var value in values)
foreach (var item in items)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
if (!Uri.TryCreate(item, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
{
error = "All redirect URIs must be absolute URIs.";
return [];
error = "Redirect URIs must be valid absolute http/https URLs.";
return new List<string>();
}
}
return values;
return items;
}
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
@ -327,33 +315,21 @@ public class OAuthClientsController : Controller
descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
if (usage == "webhook_outbound")
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.OpenId);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Email);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write");
}
else
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:events.write.global");
}
else if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:send.write");
descriptor.Permissions.Add("scp:newsletter:send.read");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");
}
return;
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write");
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write.global");
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:list.read");
}
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
descriptor.Permissions.Add("scp:openid");
}
}

View File

@ -3,8 +3,9 @@ using MemberCenter.Application.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/security")]
public class SecurityController : Controller

View File

@ -3,8 +3,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Text;
namespace MemberCenter.Web.Controllers.Admin;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/subscriptions")]
public class SubscriptionsController : Controller

View File

@ -3,8 +3,9 @@ using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/tenants")]
public class TenantsController : Controller

View File

@ -0,0 +1,2 @@
<h1>Admin</h1>
<p>Use the admin group in the main navigation to manage tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.</p>

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Member Center Admin</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body class="admin-shell">
@{
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? string.Empty;
var currentAction = ViewContext.RouteData.Values["action"]?.ToString() ?? string.Empty;
string NavClass(string controller, string action = "Index") =>
string.Equals(currentController, controller, StringComparison.OrdinalIgnoreCase)
&& string.Equals(currentAction, action, StringComparison.OrdinalIgnoreCase)
? "admin-nav-link is-active"
: "admin-nav-link";
}
<header class="admin-topbar">
<div class="container admin-topbar-inner">
<div>
<div class="admin-eyebrow">Member Center</div>
<div class="admin-title">Admin Console</div>
</div>
<nav class="admin-utility-nav">
<a asp-area="" asp-controller="Home" asp-action="Index">Member Home</a>
<a asp-area="" asp-controller="Profile" asp-action="Index">Profile</a>
</nav>
</div>
</header>
<div class="container admin-shell-layout">
<aside class="admin-sidebar">
<div class="admin-sidebar-card">
<div class="admin-sidebar-heading">Admin</div>
<p class="admin-sidebar-copy">Lightweight structure for operations screens. Visual design can be replaced later.</p>
</div>
<nav class="admin-nav" aria-label="Admin navigation">
<a class="@NavClass("Home")" asp-area="Admin" asp-controller="Home" asp-action="Index">Overview</a>
<a class="@NavClass("Tenants")" asp-area="Admin" asp-controller="Tenants" asp-action="Index">Tenants</a>
<a class="@NavClass("NewsletterLists")" asp-area="Admin" asp-controller="NewsletterLists" asp-action="Index">Newsletter Lists</a>
<a class="@NavClass("Subscriptions")" asp-area="Admin" asp-controller="Subscriptions" asp-action="Index">Subscriptions</a>
<a class="@NavClass("OAuthClients")" asp-area="Admin" asp-controller="OAuthClients" asp-action="Index">OAuth Clients</a>
<a class="@NavClass("AuditLogs")" asp-area="Admin" asp-controller="AuditLogs" asp-action="Index">Audit Logs</a>
<a class="@NavClass("Security")" asp-area="Admin" asp-controller="Security" asp-action="Index">Security</a>
<a class="@NavClass("Blacklist")" asp-area="Admin" asp-controller="Blacklist" asp-action="Index">Blacklist</a>
</nav>
</aside>
<main class="admin-content">
<div class="admin-content-header">
<div class="admin-eyebrow">Operations Area</div>
<div class="admin-content-meta">Use the left navigation to switch modules or return to the member portal from the top bar.</div>
</div>
<div class="admin-panel">
@RenderBody()
</div>
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,3 @@
@using MemberCenter.Web
@using MemberCenter.Web.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,3 @@
@{
Layout = "/Areas/Admin/Views/Shared/_Layout.cshtml";
}

View File

@ -1,3 +1,5 @@
using System.Security.Claims;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Models.Account;
using Microsoft.AspNetCore.Authorization;
@ -8,11 +10,16 @@ namespace MemberCenter.Web.Controllers;
public class AccountController : Controller
{
private readonly IAccountProvisioningService _accountProvisioningService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AccountController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
public AccountController(
IAccountProvisioningService accountProvisioningService,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_accountProvisioningService = accountProvisioningService;
_userManager = userManager;
_signInManager = signInManager;
}
@ -46,6 +53,66 @@ public class AccountController : Controller
return RedirectToAction("Index", "Home");
}
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult ExternalLogin(string provider, string? returnUrl = null)
{
var redirectUrl = Url.Action(nameof(ExternalLoginCallback), new { returnUrl });
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return Challenge(properties, provider);
}
[HttpGet]
public async Task<IActionResult> ExternalLoginCallback(string? returnUrl = null, string? remoteError = null)
{
if (!string.IsNullOrWhiteSpace(remoteError))
{
ModelState.AddModelError(string.Empty, $"External login failed: {remoteError}");
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info is null)
{
ModelState.AddModelError(string.Empty, "Unable to load external login information.");
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
var email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue("email");
var emailVerified = bool.TryParse(info.Principal.FindFirstValue("email_verified"), out var parsed) && parsed;
var result = await _accountProvisioningService.ProvisionExternalLoginAsync(
info.LoginProvider,
info.ProviderKey,
email,
emailVerified);
if (!result.Succeeded || result.UserId is null)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error);
}
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
var user = await _userManager.FindByIdAsync(result.UserId.Value.ToString());
if (user is null)
{
ModelState.AddModelError(string.Empty, "Unable to locate the linked account.");
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
await _signInManager.SignInAsync(user, false, info.LoginProvider);
if (!string.IsNullOrWhiteSpace(returnUrl))
{
return LocalRedirect(returnUrl);
}
return RedirectToAction("Index", "Home");
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Logout()
@ -54,6 +121,45 @@ public class AccountController : Controller
return RedirectToAction("Index", "Home");
}
[HttpGet]
[Authorize]
public IActionResult ChangePassword()
{
return View(new ChangePasswordViewModel());
}
[HttpPost]
[Authorize]
public async Task<IActionResult> ChangePassword(ChangePasswordViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction(nameof(Login));
}
var result = await _userManager.ChangePasswordAsync(user, model.CurrentPassword, model.NewPassword);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return View(model);
}
await _signInManager.RefreshSignInAsync(user);
ViewData["Result"] = "Password updated.";
ModelState.Clear();
return View(new ChangePasswordViewModel());
}
[HttpGet]
public IActionResult Register()
{
@ -68,20 +174,12 @@ public class AccountController : Controller
return View(model);
}
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = model.Email,
Email = model.Email,
EmailConfirmed = false
};
var result = await _userManager.CreateAsync(user, model.Password);
var result = await _accountProvisioningService.RegisterLocalAsync(model.Email, model.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
ModelState.AddModelError(string.Empty, error);
}
return View(model);
}

View File

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.19" />
<ProjectReference Include="..\MemberCenter.Application\MemberCenter.Application.csproj" />
<ProjectReference Include="..\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj" />
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Account;
public sealed class ChangePasswordViewModel
{
[Required]
[DataType(DataType.Password)]
public string CurrentPassword { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
public string NewPassword { get; set; } = string.Empty;
[Required]
[Compare(nameof(NewPassword))]
[DataType(DataType.Password)]
public string ConfirmPassword { get; set; } = string.Empty;
}

View File

@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
@ -33,9 +35,29 @@ builder.Services
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
var googleClientId = builder.Configuration["Authentication:Google:ClientId"]
?? Environment.GetEnvironmentVariable("Authentication__Google__ClientId");
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]
?? Environment.GetEnvironmentVariable("Authentication__Google__ClientSecret");
var authenticationBuilder = builder.Services.AddAuthentication();
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
{
authenticationBuilder.AddGoogle(options =>
{
options.ClientId = googleClientId;
options.ClientSecret = googleClientSecret;
});
}
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/account/login";
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = context => HandleAdminAuthRedirectAsync(context),
OnRedirectToAccessDenied = context => HandleAdminAuthRedirectAsync(context)
};
});
builder.Services.AddAuthorization(options =>
@ -50,6 +72,7 @@ builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
@ -61,12 +84,7 @@ builder.Services.AddOpenIddict()
.UseDbContext<MemberCenterDbContext>();
});
builder.Services.AddControllersWithViews()
.AddRazorOptions(options =>
{
options.ViewLocationFormats.Insert(0, "/Views/Admin/{1}/{0}.cshtml");
options.ViewLocationFormats.Insert(0, "/Views/Admin/Shared/{0}.cshtml");
});
builder.Services.AddControllersWithViews();
var app = builder.Build();
@ -80,8 +98,25 @@ app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "admin",
pattern: "admin/{controller=Home}/{action=Index}/{id?}",
defaults: new { area = "Admin" });
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
static Task HandleAdminAuthRedirectAsync(RedirectContext<CookieAuthenticationOptions> context)
{
if (context.Request.Path.StartsWithSegments("/admin", StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
}

View File

@ -0,0 +1,23 @@
@model MemberCenter.Web.Models.Account.ChangePasswordViewModel
<h1>Change Password</h1>
@if (ViewData["Result"] is string result)
{
<p>@result</p>
}
<div asp-validation-summary="All"></div>
<form method="post">
<label>Current Password</label>
<input asp-for="CurrentPassword" type="password" />
<span asp-validation-for="CurrentPassword"></span>
<label>New Password</label>
<input asp-for="NewPassword" type="password" />
<span asp-validation-for="NewPassword"></span>
<label>Confirm Password</label>
<input asp-for="ConfirmPassword" type="password" />
<span asp-validation-for="ConfirmPassword"></span>
<button type="submit">Update Password</button>
</form>

View File

@ -1,6 +1,7 @@
@model MemberCenter.Web.Models.Account.LoginViewModel
<h1>Login</h1>
<div asp-validation-summary="All"></div>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
@ -13,3 +14,9 @@
<input type="hidden" asp-for="ReturnUrl" />
<button type="submit">Login</button>
</form>
<form method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
<input type="hidden" name="provider" value="Google" />
<input type="hidden" name="returnUrl" value="@Model.ReturnUrl" />
<button type="submit">Continue with Google</button>
</form>

View File

@ -1,6 +1,8 @@
@model MemberCenter.Web.Models.Account.RegisterViewModel
<h1>Register</h1>
<p>Accounts use email as the username. New accounts are created as unverified for now.</p>
<div asp-validation-summary="All"></div>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
@ -16,3 +18,8 @@
<button type="submit">Register</button>
</form>
<form method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
<input type="hidden" name="provider" value="Google" />
<button type="submit">Register with Google</button>
</form>

View File

@ -1,2 +1,2 @@
<h1>Member Center</h1>
<p>Welcome.</p>
<p>Use this portal for account access and newsletter preferences.</p>

View File

@ -4,3 +4,4 @@
<p>Email: @Model.Email</p>
<p>Verified: @Model.EmailConfirmed</p>
<p>Created: @Model.CreatedAt</p>
<p><a asp-controller="Account" asp-action="ChangePassword">Change Password</a></p>

View File

@ -1,27 +1,64 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Member Center</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/profile">Profile</a>
<a href="/admin/tenants">Tenants</a>
<a href="/admin/newsletter-lists">Newsletter Lists</a>
<a href="/admin/subscriptions">Subscriptions</a>
<a href="/admin/oauth-clients">OAuth Clients</a>
<a href="/admin/audit-logs">Audit Logs</a>
<a href="/admin/security">Security</a>
<a href="/admin/blacklist">Blacklist</a>
<a href="/account/login">Login</a>
<form method="post" action="/account/logout" style="display:inline">
<button type="submit">Logout</button>
</form>
</nav>
<main>
<header class="border-bottom mb-4">
<div class="container py-3 d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="fw-bold">Member Center</div>
<div class="text-muted small">Client-first member portal</div>
</div>
<div class="d-flex gap-2 align-items-center">
@if (User.Identity?.IsAuthenticated ?? false)
{
<span class="text-muted small">@User.Identity!.Name</span>
<form method="post" asp-area="" asp-controller="Account" asp-action="Logout" class="m-0">
<button type="submit" class="btn btn-outline-secondary btn-sm">Logout</button>
</form>
}
else
{
<a asp-area="" asp-controller="Account" asp-action="Login" class="btn btn-outline-primary btn-sm">Login</a>
<a asp-area="" asp-controller="Account" asp-action="Register" class="btn btn-primary btn-sm">Register</a>
}
</div>
</div>
<nav class="d-flex flex-wrap gap-3 align-items-start">
<div class="d-flex flex-column">
<span class="text-muted small text-uppercase">Member</span>
<div class="d-flex gap-3 flex-wrap">
<a asp-area="" asp-controller="Home" asp-action="Index">Home</a>
<a asp-area="" asp-controller="Profile" asp-action="Index">Profile</a>
<a asp-area="" asp-controller="Newsletter" asp-action="Preferences">Preferences</a>
</div>
</div>
@if (User.IsInRole("admin"))
{
<div class="d-flex flex-column">
<span class="text-muted small text-uppercase">Admin</span>
<div class="d-flex gap-3 flex-wrap">
<a asp-area="Admin" asp-controller="Home" asp-action="Index">Overview</a>
<a asp-area="Admin" asp-controller="Tenants" asp-action="Index">Tenants</a>
<a asp-area="Admin" asp-controller="NewsletterLists" asp-action="Index">Newsletter Lists</a>
<a asp-area="Admin" asp-controller="Subscriptions" asp-action="Index">Subscriptions</a>
<a asp-area="Admin" asp-controller="OAuthClients" asp-action="Index">OAuth Clients</a>
<a asp-area="Admin" asp-controller="AuditLogs" asp-action="Index">Audit Logs</a>
<a asp-area="Admin" asp-controller="Security" asp-action="Index">Security</a>
<a asp-area="Admin" asp-controller="Blacklist" asp-action="Index">Blacklist</a>
</div>
</div>
}
</nav>
</div>
</header>
<main class="container">
@RenderBody()
</main>
</body>

View File

@ -18,5 +18,179 @@ html {
}
body {
margin-bottom: 60px;
margin: 0;
background: #f6f4ef;
color: #1f2937;
}
a {
color: #125b50;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
main.container {
padding-bottom: 3rem;
}
table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
th,
td {
padding: 0.75rem;
border-bottom: 1px solid #d1d5db;
text-align: left;
}
form {
display: grid;
gap: 0.75rem;
max-width: 40rem;
}
input,
select,
button {
font: inherit;
}
input,
select {
padding: 0.5rem 0.75rem;
}
button {
width: fit-content;
}
.validation-summary-errors,
span.field-validation-error {
color: #b42318;
}
.admin-shell {
background:
linear-gradient(180deg, rgba(13, 71, 64, 0.08), rgba(13, 71, 64, 0) 220px),
#f3efe6;
}
.admin-topbar {
border-bottom: 1px solid #d6d3cb;
background: rgba(255, 252, 246, 0.9);
backdrop-filter: blur(8px);
}
.admin-topbar-inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.25rem 0;
}
.admin-eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6b7280;
}
.admin-title {
font-size: 1.4rem;
font-weight: 700;
}
.admin-utility-nav {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.admin-shell-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 1.5rem;
padding-top: 1.5rem;
padding-bottom: 3rem;
}
.admin-sidebar {
display: grid;
gap: 1rem;
align-self: start;
}
.admin-sidebar-card,
.admin-panel {
background: rgba(255, 255, 255, 0.86);
border: 1px solid #d6d3cb;
border-radius: 18px;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.05);
}
.admin-sidebar-card {
padding: 1rem;
}
.admin-sidebar-heading {
font-weight: 700;
margin-bottom: 0.35rem;
}
.admin-sidebar-copy,
.admin-content-meta {
margin: 0;
color: #6b7280;
font-size: 0.95rem;
}
.admin-nav {
display: grid;
gap: 0.35rem;
}
.admin-nav-link {
display: block;
padding: 0.8rem 0.95rem;
border-radius: 14px;
color: #1f2937;
background: rgba(255, 255, 255, 0.55);
border: 1px solid transparent;
}
.admin-nav-link:hover {
text-decoration: none;
border-color: #c8d5d0;
background: rgba(255, 255, 255, 0.9);
}
.admin-nav-link.is-active {
background: #125b50;
color: #fff9ef;
}
.admin-content {
display: grid;
gap: 1rem;
}
.admin-content-header {
padding: 0.25rem 0.1rem;
}
.admin-panel {
padding: 1.5rem;
}
@media (max-width: 991px) {
.admin-shell-layout {
grid-template-columns: 1fr;
}
}