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:
parent
293303c989
commit
75e235b8e3
10
README.md
10
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` 事件。
|
||||
|
||||
260
docs/ADMIN_CLIENT_SPLIT_PLAN.md
Normal file
260
docs/ADMIN_CLIENT_SPLIT_PLAN.md
Normal 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
|
||||
@ -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 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
註記:此流程目前尚未在程式中落地(屬待辦)。
|
||||
|
||||
@ -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=...`
|
||||
|
||||
@ -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` 事件)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
@ -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();
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
2
src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml
Normal file
2
src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml
Normal 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>
|
||||
60
src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml
Normal file
60
src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml
Normal 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>
|
||||
@ -0,0 +1,3 @@
|
||||
@using MemberCenter.Web
|
||||
@using MemberCenter.Web.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
src/MemberCenter.Web/Areas/Admin/Views/_ViewStart.cshtml
Normal file
3
src/MemberCenter.Web/Areas/Admin/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "/Areas/Admin/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
23
src/MemberCenter.Web/Views/Account/ChangePassword.cshtml
Normal file
23
src/MemberCenter.Web/Views/Account/ChangePassword.cshtml
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
<h1>Member Center</h1>
|
||||
<p>Welcome.</p>
|
||||
<p>Use this portal for account access and newsletter preferences.</p>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -17,6 +17,180 @@ html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user