From 75e235b8e390b2b29d160dc6625f897bd535d1f9 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Wed, 1 Apr 2026 17:40:45 +0900 Subject: [PATCH] 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. --- README.md | 10 +- docs/ADMIN_CLIENT_SPLIT_PLAN.md | 260 ++++++++++++++++++ docs/DESIGN.md | 13 +- docs/FLOWS.md | 10 +- docs/UI.md | 8 +- docs/USE_CASES.md | 3 +- docs/openapi.yaml | 2 +- .../Controllers/AuthController.cs | 28 +- src/MemberCenter.Api/Program.cs | 1 + .../IAccountProvisioningService.cs | 9 + .../Account/AccountProvisioningResult.cs | 11 + .../Services/AccountProvisioningService.cs | 244 ++++++++++++++++ .../Admin/Controllers}/AuditLogsController.cs | 3 +- .../Admin/Controllers}/BlacklistController.cs | 3 +- .../Areas/Admin/Controllers/HomeController.cs | 16 ++ .../Controllers}/NewsletterListsController.cs | 3 +- .../Controllers}/OAuthClientsController.cs | 100 +++---- .../Admin/Controllers}/SecurityController.cs | 3 +- .../Controllers}/SubscriptionsController.cs | 3 +- .../Admin/Controllers}/TenantsController.cs | 3 +- .../Admin/Views}/AuditLogs/Index.cshtml | 0 .../Admin/Views}/Blacklist/Create.cshtml | 0 .../Admin/Views}/Blacklist/Index.cshtml | 0 .../Areas/Admin/Views/Home/Index.cshtml | 2 + .../Views}/NewsletterLists/Create.cshtml | 0 .../Admin/Views}/NewsletterLists/Edit.cshtml | 0 .../Admin/Views}/NewsletterLists/Index.cshtml | 0 .../Admin/Views}/OAuthClients/Create.cshtml | 0 .../Admin/Views}/OAuthClients/Edit.cshtml | 0 .../Admin/Views}/OAuthClients/Index.cshtml | 0 .../Admin/Views}/Security/Index.cshtml | 0 .../Areas/Admin/Views/Shared/_Layout.cshtml | 60 ++++ .../Admin/Views}/Subscriptions/Index.cshtml | 0 .../Admin/Views}/Tenants/Create.cshtml | 0 .../Admin/Views}/Tenants/Edit.cshtml | 0 .../Admin/Views}/Tenants/Index.cshtml | 0 .../Areas/Admin/Views/_ViewImports.cshtml | 3 + .../Areas/Admin/Views/_ViewStart.cshtml | 3 + .../Controllers/AccountController.cs | 120 +++++++- src/MemberCenter.Web/MemberCenter.Web.csproj | 3 +- .../Models/Account/ChangePasswordViewModel.cs | 19 ++ src/MemberCenter.Web/Program.cs | 47 +++- .../Views/Account/ChangePassword.cshtml | 23 ++ .../Views/Account/Login.cshtml | 7 + .../Views/Account/Register.cshtml | 7 + src/MemberCenter.Web/Views/Home/Index.cshtml | 2 +- .../Views/Profile/Index.cshtml | 1 + .../Views/Shared/_Layout.cshtml | 71 +++-- src/MemberCenter.Web/wwwroot/css/site.css | 180 +++++++++++- 49 files changed, 1149 insertions(+), 132 deletions(-) create mode 100644 docs/ADMIN_CLIENT_SPLIT_PLAN.md create mode 100644 src/MemberCenter.Application/Abstractions/IAccountProvisioningService.cs create mode 100644 src/MemberCenter.Application/Models/Account/AccountProvisioningResult.cs create mode 100644 src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs rename src/MemberCenter.Web/{Controllers/Admin => Areas/Admin/Controllers}/AuditLogsController.cs (89%) rename src/MemberCenter.Web/{Controllers/Admin => Areas/Admin/Controllers}/BlacklistController.cs (94%) create mode 100644 src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs rename src/MemberCenter.Web/{Controllers/Admin => Areas/Admin/Controllers}/NewsletterListsController.cs (97%) rename src/MemberCenter.Web/{Controllers/Admin => Areas/Admin/Controllers}/OAuthClientsController.cs (77%) rename src/MemberCenter.Web/{Controllers/Admin => Areas/Admin/Controllers}/SecurityController.cs (93%) rename src/MemberCenter.Web/{Controllers/Admin => Areas/Admin/Controllers}/SubscriptionsController.cs (94%) rename src/MemberCenter.Web/{Controllers/Admin => Areas/Admin/Controllers}/TenantsController.cs (98%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/AuditLogs/Index.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/Blacklist/Create.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/Blacklist/Index.cshtml (100%) create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/NewsletterLists/Create.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/NewsletterLists/Edit.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/NewsletterLists/Index.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/OAuthClients/Create.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/OAuthClients/Edit.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/OAuthClients/Index.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/Security/Index.cshtml (100%) create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/Subscriptions/Index.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/Tenants/Create.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/Tenants/Edit.cshtml (100%) rename src/MemberCenter.Web/{Views/Admin => Areas/Admin/Views}/Tenants/Index.cshtml (100%) create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/_ViewStart.cshtml create mode 100644 src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs create mode 100644 src/MemberCenter.Web/Views/Account/ChangePassword.cshtml diff --git a/README.md b/README.md index 6541df3..2a053bd 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ member_center/ ├── src/ │ ├── MemberCenter.Api/ # REST API(OAuth/OIDC、訂閱、管理 API) -│ ├── MemberCenter.Web/ # MVC Web UI(會員與後台頁面) +│ ├── MemberCenter.Web/ # MVC Web UI(client-first 會員介面 + Areas/Admin 管理介面) │ ├── MemberCenter.Installer/ # 安裝與初始化 CLI(migrate/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` 事件。 diff --git a/docs/ADMIN_CLIENT_SPLIT_PLAN.md b/docs/ADMIN_CLIENT_SPLIT_PLAN.md new file mode 100644 index 0000000..198e574 --- /dev/null +++ b/docs/ADMIN_CLIENT_SPLIT_PLAN.md @@ -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 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 80987a1..a53870f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足 diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 2cf2423..100805c 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -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` - -註記:此流程目前尚未在程式中落地(屬待辦)。 diff --git a/docs/UI.md b/docs/UI.md index d0e686c..7d1111b 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -2,7 +2,7 @@ ## 會員中心(統一 UI) ### 會員端 -- 註冊 / 登入 / 忘記密碼 +- 註冊 / 登入 / 忘記密碼 / 修改密碼 - Email 驗證 - 個人資料(Email 為主) - 訂閱管理(清單與偏好) @@ -19,7 +19,7 @@ ## 各站自建 UI(API) ### 會員端 -- 登入 / 註冊 / 忘記密碼 +- 登入 / 註冊 / 忘記密碼 / 修改密碼 - 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=...` diff --git a/docs/USE_CASES.md b/docs/USE_CASES.md index 1ee0e64..bc62f63 100644 --- a/docs/USE_CASES.md +++ b/docs/USE_CASES.md @@ -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` 事件) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2f22f62..9293ae7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/src/MemberCenter.Api/Controllers/AuthController.cs b/src/MemberCenter.Api/Controllers/AuthController.cs index df6e9a4..1c68ea1 100644 --- a/src/MemberCenter.Api/Controllers/AuthController.cs +++ b/src/MemberCenter.Api/Controllers/AuthController.cs @@ -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 _userManager; private readonly SignInManager _signInManager; - public AuthController(UserManager userManager, SignInManager signInManager) + public AuthController( + IAccountProvisioningService accountProvisioningService, + UserManager userManager, + SignInManager signInManager) { + _accountProvisioningService = accountProvisioningService; _userManager = userManager; _signInManager = signInManager; } @@ -22,26 +28,18 @@ public class AuthController : ControllerBase [HttpPost("register")] public async Task 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 }); } diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index b20a96b..f7e4592 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -127,6 +127,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); diff --git a/src/MemberCenter.Application/Abstractions/IAccountProvisioningService.cs b/src/MemberCenter.Application/Abstractions/IAccountProvisioningService.cs new file mode 100644 index 0000000..39e98d6 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAccountProvisioningService.cs @@ -0,0 +1,9 @@ +using MemberCenter.Application.Models.Account; + +namespace MemberCenter.Application.Abstractions; + +public interface IAccountProvisioningService +{ + Task RegisterLocalAsync(string email, string password); + Task ProvisionExternalLoginAsync(string loginProvider, string providerKey, string? email, bool emailVerified); +} diff --git a/src/MemberCenter.Application/Models/Account/AccountProvisioningResult.cs b/src/MemberCenter.Application/Models/Account/AccountProvisioningResult.cs new file mode 100644 index 0000000..bbec0df --- /dev/null +++ b/src/MemberCenter.Application/Models/Account/AccountProvisioningResult.cs @@ -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 Errors); diff --git a/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs b/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs new file mode 100644 index 0000000..1c76832 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs @@ -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 _userManager; + private readonly MemberCenterDbContext _dbContext; + private readonly ISendEngineWebhookPublisher _webhookPublisher; + private readonly ILogger _logger; + + public AccountProvisioningService( + UserManager userManager, + MemberCenterDbContext dbContext, + ISendEngineWebhookPublisher webhookPublisher, + ILogger logger) + { + _userManager = userManager; + _dbContext = dbContext; + _webhookPublisher = webhookPublisher; + _logger = logger; + } + + public async Task 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()); + } + + public async Task 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()); + } + + 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()); + } + + private async Task 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 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(); +} diff --git a/src/MemberCenter.Web/Controllers/Admin/AuditLogsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/AuditLogsController.cs similarity index 89% rename from src/MemberCenter.Web/Controllers/Admin/AuditLogsController.cs rename to src/MemberCenter.Web/Areas/Admin/Controllers/AuditLogsController.cs index 21e77f7..ee5e262 100644 --- a/src/MemberCenter.Web/Controllers/Admin/AuditLogsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/AuditLogsController.cs @@ -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 diff --git a/src/MemberCenter.Web/Controllers/Admin/BlacklistController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/BlacklistController.cs similarity index 94% rename from src/MemberCenter.Web/Controllers/Admin/BlacklistController.cs rename to src/MemberCenter.Web/Areas/Admin/Controllers/BlacklistController.cs index 213e94a..af12ab1 100644 --- a/src/MemberCenter.Web/Controllers/Admin/BlacklistController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/BlacklistController.cs @@ -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 diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs new file mode 100644 index 0000000..d99fae2 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs @@ -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(); + } +} diff --git a/src/MemberCenter.Web/Controllers/Admin/NewsletterListsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs similarity index 97% rename from src/MemberCenter.Web/Controllers/Admin/NewsletterListsController.cs rename to src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs index 6da7f67..b974943 100644 --- a/src/MemberCenter.Web/Controllers/Admin/NewsletterListsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs @@ -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 diff --git a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs similarity index 77% rename from src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs rename to src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs index 19e7273..decf46a 100644 --- a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs @@ -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 NormalizeRedirectUris(string redirectUrisText, out string? error) + private static List 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(); } } - 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"); } } diff --git a/src/MemberCenter.Web/Controllers/Admin/SecurityController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs similarity index 93% rename from src/MemberCenter.Web/Controllers/Admin/SecurityController.cs rename to src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs index f3b58d6..51beb3d 100644 --- a/src/MemberCenter.Web/Controllers/Admin/SecurityController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs @@ -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 diff --git a/src/MemberCenter.Web/Controllers/Admin/SubscriptionsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/SubscriptionsController.cs similarity index 94% rename from src/MemberCenter.Web/Controllers/Admin/SubscriptionsController.cs rename to src/MemberCenter.Web/Areas/Admin/Controllers/SubscriptionsController.cs index d6e80c0..d6098b3 100644 --- a/src/MemberCenter.Web/Controllers/Admin/SubscriptionsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/SubscriptionsController.cs @@ -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 diff --git a/src/MemberCenter.Web/Controllers/Admin/TenantsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs similarity index 98% rename from src/MemberCenter.Web/Controllers/Admin/TenantsController.cs rename to src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs index 0647ccd..9eda581 100644 --- a/src/MemberCenter.Web/Controllers/Admin/TenantsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs @@ -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 diff --git a/src/MemberCenter.Web/Views/Admin/AuditLogs/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/AuditLogs/Index.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/Blacklist/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/Blacklist/Create.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/Blacklist/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/Blacklist/Index.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml new file mode 100644 index 0000000..034c996 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml @@ -0,0 +1,2 @@ +

Admin

+

Use the admin group in the main navigation to manage tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.

diff --git a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/NewsletterLists/Create.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Edit.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/NewsletterLists/Edit.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Edit.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/NewsletterLists/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/NewsletterLists/Index.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/Security/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/Security/Index.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..734ea1c --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml @@ -0,0 +1,60 @@ + + + + + + Member Center Admin + + + + + @{ + 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"; + } +
+
+
+
Member Center
+
Admin Console
+
+ +
+
+
+ +
+
+
Operations Area
+ +
+
+ @RenderBody() +
+
+
+ + diff --git a/src/MemberCenter.Web/Views/Admin/Subscriptions/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/Subscriptions/Index.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/Tenants/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/Tenants/Create.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/Tenants/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/Tenants/Edit.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml diff --git a/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml similarity index 100% rename from src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml rename to src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml diff --git a/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml new file mode 100644 index 0000000..d2d0d15 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using MemberCenter.Web +@using MemberCenter.Web.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/MemberCenter.Web/Areas/Admin/Views/_ViewStart.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/_ViewStart.cshtml new file mode 100644 index 0000000..1c628a0 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "/Areas/Admin/Views/Shared/_Layout.cshtml"; +} diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index 6962477..1f4dad5 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -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 _userManager; private readonly SignInManager _signInManager; - public AccountController(UserManager userManager, SignInManager signInManager) + public AccountController( + IAccountProvisioningService accountProvisioningService, + UserManager userManager, + SignInManager 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 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 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 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); } diff --git a/src/MemberCenter.Web/MemberCenter.Web.csproj b/src/MemberCenter.Web/MemberCenter.Web.csproj index ff81230..ae7c282 100644 --- a/src/MemberCenter.Web/MemberCenter.Web.csproj +++ b/src/MemberCenter.Web/MemberCenter.Web.csproj @@ -1,10 +1,11 @@ + - + net8.0 diff --git a/src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs b/src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs new file mode 100644 index 0000000..5db017a --- /dev/null +++ b/src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs @@ -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; +} diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index dd445bc..38dc421 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -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() .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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); @@ -61,12 +84,7 @@ builder.Services.AddOpenIddict() .UseDbContext(); }); -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 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; +} diff --git a/src/MemberCenter.Web/Views/Account/ChangePassword.cshtml b/src/MemberCenter.Web/Views/Account/ChangePassword.cshtml new file mode 100644 index 0000000..5295a39 --- /dev/null +++ b/src/MemberCenter.Web/Views/Account/ChangePassword.cshtml @@ -0,0 +1,23 @@ +@model MemberCenter.Web.Models.Account.ChangePasswordViewModel + +

Change Password

+@if (ViewData["Result"] is string result) +{ +

@result

+} +
+
+ + + + + + + + + + + + + +
diff --git a/src/MemberCenter.Web/Views/Account/Login.cshtml b/src/MemberCenter.Web/Views/Account/Login.cshtml index d13347e..48776bc 100644 --- a/src/MemberCenter.Web/Views/Account/Login.cshtml +++ b/src/MemberCenter.Web/Views/Account/Login.cshtml @@ -1,6 +1,7 @@ @model MemberCenter.Web.Models.Account.LoginViewModel

Login

+
@@ -13,3 +14,9 @@
+ +
+ + + +
diff --git a/src/MemberCenter.Web/Views/Account/Register.cshtml b/src/MemberCenter.Web/Views/Account/Register.cshtml index 12108b7..e1986bd 100644 --- a/src/MemberCenter.Web/Views/Account/Register.cshtml +++ b/src/MemberCenter.Web/Views/Account/Register.cshtml @@ -1,6 +1,8 @@ @model MemberCenter.Web.Models.Account.RegisterViewModel

Register

+

Accounts use email as the username. New accounts are created as unverified for now.

+
@@ -16,3 +18,8 @@
+ +
+ + +
diff --git a/src/MemberCenter.Web/Views/Home/Index.cshtml b/src/MemberCenter.Web/Views/Home/Index.cshtml index a820445..d52490f 100644 --- a/src/MemberCenter.Web/Views/Home/Index.cshtml +++ b/src/MemberCenter.Web/Views/Home/Index.cshtml @@ -1,2 +1,2 @@

Member Center

-

Welcome.

+

Use this portal for account access and newsletter preferences.

diff --git a/src/MemberCenter.Web/Views/Profile/Index.cshtml b/src/MemberCenter.Web/Views/Profile/Index.cshtml index 246fe48..61c916e 100644 --- a/src/MemberCenter.Web/Views/Profile/Index.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Index.cshtml @@ -4,3 +4,4 @@

Email: @Model.Email

Verified: @Model.EmailConfirmed

Created: @Model.CreatedAt

+

Change Password

diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index e1a92d8..7e612da 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -1,27 +1,64 @@ - + Member Center + + - -
+
+
+
+
+
Member Center
+
Client-first member portal
+
+
+ @if (User.Identity?.IsAuthenticated ?? false) + { + @User.Identity!.Name +
+ +
+ } + else + { + Login + Register + } +
+
+ +
+
+
@RenderBody()
diff --git a/src/MemberCenter.Web/wwwroot/css/site.css b/src/MemberCenter.Web/wwwroot/css/site.css index f8a013d..4817eae 100644 --- a/src/MemberCenter.Web/wwwroot/css/site.css +++ b/src/MemberCenter.Web/wwwroot/css/site.css @@ -17,6 +17,180 @@ html { min-height: 100%; } -body { - margin-bottom: 60px; -} \ No newline at end of file +body { + 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; + } +}