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/
|
member_center/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── MemberCenter.Api/ # REST API(OAuth/OIDC、訂閱、管理 API)
|
│ ├── 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.Installer/ # 安裝與初始化 CLI(migrate/init/admin)
|
||||||
│ ├── MemberCenter.Application/ # 應用層介面與 DTO
|
│ ├── MemberCenter.Application/ # 應用層介面與 DTO
|
||||||
│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作
|
│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作
|
||||||
@ -66,3 +66,11 @@ member_center/
|
|||||||
- 事件系統選擇(Kafka/RabbitMQ/SNS+SQS)
|
- 事件系統選擇(Kafka/RabbitMQ/SNS+SQS)
|
||||||
- 取消訂閱的 UI 形式(純一鍵或提供偏好)
|
- 取消訂閱的 UI 形式(純一鍵或提供偏好)
|
||||||
- GDPR/CCPA 資料匯出與刪除是否納入第一期
|
- 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. 核心模組
|
## 4. 核心模組
|
||||||
- Identity Service:註冊、登入、密碼重設、Email 驗證
|
- Identity Service:註冊、登入、修改密碼、密碼重設、Email 驗證
|
||||||
- OAuth2/OIDC Service:授權流程、token 發放、ID Token
|
- OAuth2/OIDC Service:授權流程、token 發放、ID Token
|
||||||
- Subscription Service:訂閱/退訂/偏好管理
|
- Subscription Service:訂閱/退訂/偏好管理
|
||||||
- Admin Console:租戶與清單管理
|
- Admin Console:租戶與清單管理
|
||||||
@ -124,6 +124,13 @@
|
|||||||
3) 將 `user_id` 補上並保留偏好
|
3) 將 `user_id` 補上並保留偏好
|
||||||
4) 可選:發出事件 `subscription.linked_to_user`
|
4) 可選:發出事件 `subscription.linked_to_user`
|
||||||
|
|
||||||
|
### 6.8 已登入修改密碼
|
||||||
|
1) 使用者登入會員中心
|
||||||
|
2) 進入 change password 頁面
|
||||||
|
3) 提交 `current_password + new_password`
|
||||||
|
4) 系統驗證目前密碼正確後更新 password hash
|
||||||
|
5) 更新成功後刷新目前 session
|
||||||
|
|
||||||
## 7. API 介面(草案)
|
## 7. API 介面(草案)
|
||||||
- GET `/oauth/authorize`
|
- GET `/oauth/authorize`
|
||||||
- POST `/oauth/token`
|
- POST `/oauth/token`
|
||||||
@ -167,8 +174,8 @@
|
|||||||
|
|
||||||
## 7.2 尚未完成(待辦)
|
## 7.2 尚未完成(待辦)
|
||||||
- `POST /webhooks/lists/full-sync`:Member Center 端尚未發送此事件(僅保留契約)
|
- `POST /webhooks/lists/full-sync`:Member Center 端尚未發送此事件(僅保留契約)
|
||||||
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)尚未在註冊流程落地
|
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)已在註冊 / external login 流程落地
|
||||||
- `subscription.linked_to_user` 事件尚未發送
|
- `subscription.linked_to_user` 事件已發送
|
||||||
- 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
|
- 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
|
||||||
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
|
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,14 @@
|
|||||||
- [UI] 會員中心頁提交 email 並發送重設信
|
- [UI] 會員中心頁提交 email 並發送重設信
|
||||||
- [API/UI] 使用 token 進入重設密碼頁
|
- [API/UI] 使用 token 進入重設密碼頁
|
||||||
|
|
||||||
|
註記:目前 Web UI 已實作 forgot/reset 流程,但尚未串接 email 發送;開發階段會直接顯示 reset token 與 reset 連結。
|
||||||
|
|
||||||
|
## F-03b 已登入修改密碼
|
||||||
|
- [UI] 使用者登入後進入 `/account/changepassword`
|
||||||
|
- [UI] 輸入目前密碼與新密碼
|
||||||
|
- [UI] 會員中心驗證目前密碼後更新密碼
|
||||||
|
- [UI] 更新成功後刷新登入狀態
|
||||||
|
|
||||||
## F-04 訂閱電子報(未登入)
|
## F-04 訂閱電子報(未登入)
|
||||||
- [API] 站點送出 `POST /newsletter/subscribe`
|
- [API] 站點送出 `POST /newsletter/subscribe`
|
||||||
- [API] 會員中心建立 pending 訂閱並發送驗證信
|
- [API] 會員中心建立 pending 訂閱並發送驗證信
|
||||||
@ -81,5 +89,3 @@
|
|||||||
## F-09 訂閱與會員綁定
|
## F-09 訂閱與會員綁定
|
||||||
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
|
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
|
||||||
- [API] 發送事件 `subscription.linked_to_user`
|
- [API] 發送事件 `subscription.linked_to_user`
|
||||||
|
|
||||||
註記:此流程目前尚未在程式中落地(屬待辦)。
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 會員中心(統一 UI)
|
## 會員中心(統一 UI)
|
||||||
### 會員端
|
### 會員端
|
||||||
- 註冊 / 登入 / 忘記密碼
|
- 註冊 / 登入 / 忘記密碼 / 修改密碼
|
||||||
- Email 驗證
|
- Email 驗證
|
||||||
- 個人資料(Email 為主)
|
- 個人資料(Email 為主)
|
||||||
- 訂閱管理(清單與偏好)
|
- 訂閱管理(清單與偏好)
|
||||||
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
## 各站自建 UI(API)
|
## 各站自建 UI(API)
|
||||||
### 會員端
|
### 會員端
|
||||||
- 登入 / 註冊 / 忘記密碼
|
- 登入 / 註冊 / 忘記密碼 / 修改密碼
|
||||||
- Email 驗證頁(可自建或導回會員中心)
|
- Email 驗證頁(可自建或導回會員中心)
|
||||||
- 訂閱表單(未登入)
|
- 訂閱表單(未登入)
|
||||||
- 訂閱偏好管理(登入後)
|
- 訂閱偏好管理(登入後)
|
||||||
@ -33,6 +33,9 @@
|
|||||||
- 會員中心 UI 為統一入口(少數情境)
|
- 會員中心 UI 為統一入口(少數情境)
|
||||||
- 其餘皆走 API 與各站自建 UI
|
- 其餘皆走 API 與各站自建 UI
|
||||||
- 會員中心 UI 不承擔行銷內容或寄送
|
- 會員中心 UI 不承擔行銷內容或寄送
|
||||||
|
- `MemberCenter.Web` 採 client-first 介面;admin 功能以角色判斷後顯示於同一登入入口內
|
||||||
|
- `/admin/*` 由 `Areas/Admin` 提供獨立管理區殼層
|
||||||
|
- 非 admin 或未登入存取 `/admin/*` 時,回 `404`
|
||||||
## UI 路徑對應(Use Cases)
|
## UI 路徑對應(Use Cases)
|
||||||
|
|
||||||
### 會員端(統一 UI)
|
### 會員端(統一 UI)
|
||||||
@ -40,6 +43,7 @@
|
|||||||
- UC-02 登入: `/account/login`
|
- UC-02 登入: `/account/login`
|
||||||
- UC-03 登出: `POST /account/logout`
|
- UC-03 登出: `POST /account/logout`
|
||||||
- UC-04 忘記密碼 / 重設密碼: `/account/forgotpassword`, `/account/resetpassword`
|
- UC-04 忘記密碼 / 重設密碼: `/account/forgotpassword`, `/account/resetpassword`
|
||||||
|
- UC-04.1 已登入修改密碼: `/account/changepassword`
|
||||||
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
|
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
|
||||||
- UC-07 訂閱確認(double opt-in): `/newsletter/confirm?token=...`
|
- UC-07 訂閱確認(double opt-in): `/newsletter/confirm?token=...`
|
||||||
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
|
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
- UC-02 登入(取得 token) [API/UI]
|
- UC-02 登入(取得 token) [API/UI]
|
||||||
- UC-03 登出 [API/UI]
|
- UC-03 登出 [API/UI]
|
||||||
- UC-04 忘記密碼 / 重設密碼 [API/UI]
|
- UC-04 忘記密碼 / 重設密碼 [API/UI]
|
||||||
|
- UC-04.1 已登入修改密碼 [UI]
|
||||||
- UC-05 Email 驗證 [API/UI]
|
- UC-05 Email 驗證 [API/UI]
|
||||||
- UC-06 訂閱電子報(未登入) [API]
|
- UC-06 訂閱電子報(未登入) [API]
|
||||||
- UC-07 訂閱確認(double opt-in) [UI]
|
- UC-07 訂閱確認(double opt-in) [UI]
|
||||||
@ -31,4 +32,4 @@
|
|||||||
|
|
||||||
## 實作狀態(2026-02)
|
## 實作狀態(2026-02)
|
||||||
- 已完成:UC-17、UC-18(以 webhook 事件發送)
|
- 已完成: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]
|
required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at]
|
||||||
properties:
|
properties:
|
||||||
event_id: { type: string }
|
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 }
|
tenant_id: { type: string }
|
||||||
list_id: { type: string }
|
list_id: { type: string }
|
||||||
subscriber:
|
subscriber:
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using MemberCenter.Api.Contracts;
|
using MemberCenter.Api.Contracts;
|
||||||
|
using MemberCenter.Application.Abstractions;
|
||||||
using MemberCenter.Infrastructure.Identity;
|
using MemberCenter.Infrastructure.Identity;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@ -10,11 +11,16 @@ namespace MemberCenter.Api.Controllers;
|
|||||||
[Route("auth")]
|
[Route("auth")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly IAccountProvisioningService _accountProvisioningService;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
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;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
}
|
}
|
||||||
@ -22,26 +28,18 @@ public class AuthController : ControllerBase
|
|||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
var user = new ApplicationUser
|
var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserName = request.Email,
|
|
||||||
Email = request.Email,
|
|
||||||
EmailConfirmed = false
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await _userManager.CreateAsync(user, request.Password);
|
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
return BadRequest(result.Errors.Select(e => e.Description));
|
return BadRequest(result.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
id = user.Id,
|
id = result.UserId,
|
||||||
email = user.Email,
|
email = result.Email,
|
||||||
email_verified = user.EmailConfirmed,
|
email_verified = result.EmailConfirmed,
|
||||||
created_at = user.CreatedAt
|
linked_subscriptions = result.LinkedSubscriptionsCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,6 +127,7 @@ builder.Services.AddScoped<INewsletterService, NewsletterService>();
|
|||||||
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
||||||
builder.Services.AddScoped<ITenantService, TenantService>();
|
builder.Services.AddScoped<ITenantService, TenantService>();
|
||||||
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
||||||
|
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
|
||||||
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||||
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||||
builder.Services.AddScoped<ISendEngineWebhookPublisher, 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.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers.Admin;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
[Authorize(Policy = "Admin")]
|
[Authorize(Policy = "Admin")]
|
||||||
[Route("admin/audit-logs")]
|
[Route("admin/audit-logs")]
|
||||||
public class AuditLogsController : Controller
|
public class AuditLogsController : Controller
|
||||||
@ -3,8 +3,9 @@ using MemberCenter.Web.Models.Admin;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers.Admin;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
[Authorize(Policy = "Admin")]
|
[Authorize(Policy = "Admin")]
|
||||||
[Route("admin/blacklist")]
|
[Route("admin/blacklist")]
|
||||||
public class BlacklistController : Controller
|
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.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers.Admin;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
[Authorize(Policy = "Admin")]
|
[Authorize(Policy = "Admin")]
|
||||||
[Route("admin/newsletter-lists")]
|
[Route("admin/newsletter-lists")]
|
||||||
public class NewsletterListsController : Controller
|
public class NewsletterListsController : Controller
|
||||||
@ -1,11 +1,12 @@
|
|||||||
using MemberCenter.Web.Models.Admin;
|
|
||||||
using MemberCenter.Application.Abstractions;
|
using MemberCenter.Application.Abstractions;
|
||||||
|
using MemberCenter.Web.Models.Admin;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using OpenIddict.Abstractions;
|
using OpenIddict.Abstractions;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers.Admin;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
[Authorize(Policy = "Admin")]
|
[Authorize(Policy = "Admin")]
|
||||||
[Route("admin/oauth-clients")]
|
[Route("admin/oauth-clients")]
|
||||||
public class OAuthClientsController : Controller
|
public class OAuthClientsController : Controller
|
||||||
@ -38,7 +39,7 @@ public class OAuthClientsController : Controller
|
|||||||
name = await _applicationManager.GetDisplayNameAsync(application),
|
name = await _applicationManager.GetDisplayNameAsync(application),
|
||||||
client_id = await _applicationManager.GetClientIdAsync(application),
|
client_id = await _applicationManager.GetClientIdAsync(application),
|
||||||
client_type = await _applicationManager.GetClientTypeAsync(application),
|
client_type = await _applicationManager.GetClientTypeAsync(application),
|
||||||
usage = usage,
|
usage,
|
||||||
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
|
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -80,6 +81,7 @@ public class OAuthClientsController : Controller
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
|
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["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
||||||
|
|
||||||
await _applicationManager.CreateAsync(descriptor);
|
await _applicationManager.CreateAsync(descriptor);
|
||||||
@ -175,6 +178,7 @@ public class OAuthClientsController : Controller
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
|
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));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
|
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
|
||||||
{
|
{
|
||||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
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.Remove("tenant_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
||||||
|
|
||||||
await _applicationManager.UpdateAsync(app, descriptor);
|
await _applicationManager.UpdateAsync(app, descriptor);
|
||||||
@ -259,54 +265,36 @@ public class OAuthClientsController : Controller
|
|||||||
return RedirectToAction("Index");
|
return RedirectToAction("Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsValidUsage(string usage)
|
private static bool IsValidUsage(string usage) =>
|
||||||
{
|
usage is "tenant_api" or "send_api" or "webhook_outbound" or "platform_service";
|
||||||
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 IsTenantOptionalUsage(string usage)
|
private static bool IsTenantOptionalUsage(string usage) =>
|
||||||
{
|
usage == "platform_service";
|
||||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool RequiresClientCredentials(string usage)
|
private static bool UsesAuthorizationCodeFlow(string usage) =>
|
||||||
{
|
usage == "webhook_outbound";
|
||||||
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)
|
private static bool RequiresClientCredentials(string usage) =>
|
||||||
{
|
usage is "tenant_api" or "send_api" or "platform_service";
|
||||||
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<string> NormalizeRedirectUris(string redirectUrisText, out string? error)
|
private static List<string> NormalizeRedirectUris(string? value, out string? error)
|
||||||
{
|
{
|
||||||
error = null;
|
error = null;
|
||||||
if (string.IsNullOrWhiteSpace(redirectUrisText))
|
var items = (value ?? string.Empty)
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var values = redirectUrisText
|
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
.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.";
|
error = "Redirect URIs must be valid absolute http/https URLs.";
|
||||||
return [];
|
return new List<string>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return values;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
|
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.Clear();
|
||||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||||
|
|
||||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
if (usage == "webhook_outbound")
|
||||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
{
|
||||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
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);
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write");
|
||||||
{
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write.global");
|
||||||
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:list.read");
|
||||||
}
|
|
||||||
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.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.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers.Admin;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
[Authorize(Policy = "Admin")]
|
[Authorize(Policy = "Admin")]
|
||||||
[Route("admin/security")]
|
[Route("admin/security")]
|
||||||
public class SecurityController : Controller
|
public class SecurityController : Controller
|
||||||
@ -3,8 +3,9 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers.Admin;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
[Authorize(Policy = "Admin")]
|
[Authorize(Policy = "Admin")]
|
||||||
[Route("admin/subscriptions")]
|
[Route("admin/subscriptions")]
|
||||||
public class SubscriptionsController : Controller
|
public class SubscriptionsController : Controller
|
||||||
@ -3,8 +3,9 @@ using MemberCenter.Web.Models.Admin;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers.Admin;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
[Authorize(Policy = "Admin")]
|
[Authorize(Policy = "Admin")]
|
||||||
[Route("admin/tenants")]
|
[Route("admin/tenants")]
|
||||||
public class TenantsController : Controller
|
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.Infrastructure.Identity;
|
||||||
using MemberCenter.Web.Models.Account;
|
using MemberCenter.Web.Models.Account;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@ -8,11 +10,16 @@ namespace MemberCenter.Web.Controllers;
|
|||||||
|
|
||||||
public class AccountController : Controller
|
public class AccountController : Controller
|
||||||
{
|
{
|
||||||
|
private readonly IAccountProvisioningService _accountProvisioningService;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
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;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
}
|
}
|
||||||
@ -46,6 +53,66 @@ public class AccountController : Controller
|
|||||||
return RedirectToAction("Index", "Home");
|
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]
|
[HttpPost]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> Logout()
|
public async Task<IActionResult> Logout()
|
||||||
@ -54,6 +121,45 @@ public class AccountController : Controller
|
|||||||
return RedirectToAction("Index", "Home");
|
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]
|
[HttpGet]
|
||||||
public IActionResult Register()
|
public IActionResult Register()
|
||||||
{
|
{
|
||||||
@ -68,20 +174,12 @@ public class AccountController : Controller
|
|||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = new ApplicationUser
|
var result = await _accountProvisioningService.RegisterLocalAsync(model.Email, model.Password);
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserName = model.Email,
|
|
||||||
Email = model.Email,
|
|
||||||
EmailConfirmed = false
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await _userManager.CreateAsync(user, model.Password);
|
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
foreach (var error in result.Errors)
|
foreach (var error in result.Errors)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
ModelState.AddModelError(string.Empty, error);
|
||||||
}
|
}
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.19" />
|
||||||
<ProjectReference Include="..\MemberCenter.Application\MemberCenter.Application.csproj" />
|
<ProjectReference Include="..\MemberCenter.Application\MemberCenter.Application.csproj" />
|
||||||
<ProjectReference Include="..\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj" />
|
<ProjectReference Include="..\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
|
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
|
||||||
|
|||||||
@ -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.Application.Abstractions;
|
||||||
using MemberCenter.Infrastructure.Configuration;
|
using MemberCenter.Infrastructure.Configuration;
|
||||||
using MemberCenter.Infrastructure.Identity;
|
using MemberCenter.Infrastructure.Identity;
|
||||||
@ -33,9 +35,29 @@ builder.Services
|
|||||||
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.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 =>
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
{
|
{
|
||||||
options.LoginPath = "/account/login";
|
options.LoginPath = "/account/login";
|
||||||
|
options.Events = new CookieAuthenticationEvents
|
||||||
|
{
|
||||||
|
OnRedirectToLogin = context => HandleAdminAuthRedirectAsync(context),
|
||||||
|
OnRedirectToAccessDenied = context => HandleAdminAuthRedirectAsync(context)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthorization(options =>
|
builder.Services.AddAuthorization(options =>
|
||||||
@ -50,6 +72,7 @@ builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
|||||||
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
|
||||||
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
||||||
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
|
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
|
||||||
|
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
|
||||||
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||||
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||||
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
||||||
@ -61,12 +84,7 @@ builder.Services.AddOpenIddict()
|
|||||||
.UseDbContext<MemberCenterDbContext>();
|
.UseDbContext<MemberCenterDbContext>();
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllersWithViews()
|
builder.Services.AddControllersWithViews();
|
||||||
.AddRazorOptions(options =>
|
|
||||||
{
|
|
||||||
options.ViewLocationFormats.Insert(0, "/Views/Admin/{1}/{0}.cshtml");
|
|
||||||
options.ViewLocationFormats.Insert(0, "/Views/Admin/Shared/{0}.cshtml");
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@ -80,8 +98,25 @@ app.UseRouting();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllerRoute(
|
||||||
|
name: "admin",
|
||||||
|
pattern: "admin/{controller=Home}/{action=Index}/{id?}",
|
||||||
|
defaults: new { area = "Admin" });
|
||||||
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
|
|
||||||
app.Run();
|
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
|
@model MemberCenter.Web.Models.Account.LoginViewModel
|
||||||
|
|
||||||
<h1>Login</h1>
|
<h1>Login</h1>
|
||||||
|
<div asp-validation-summary="All"></div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input asp-for="Email" />
|
<input asp-for="Email" />
|
||||||
@ -13,3 +14,9 @@
|
|||||||
<input type="hidden" asp-for="ReturnUrl" />
|
<input type="hidden" asp-for="ReturnUrl" />
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</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
|
@model MemberCenter.Web.Models.Account.RegisterViewModel
|
||||||
|
|
||||||
<h1>Register</h1>
|
<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">
|
<form method="post">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input asp-for="Email" />
|
<input asp-for="Email" />
|
||||||
@ -16,3 +18,8 @@
|
|||||||
|
|
||||||
<button type="submit">Register</button>
|
<button type="submit">Register</button>
|
||||||
</form>
|
</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>
|
<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>Email: @Model.Email</p>
|
||||||
<p>Verified: @Model.EmailConfirmed</p>
|
<p>Verified: @Model.EmailConfirmed</p>
|
||||||
<p>Created: @Model.CreatedAt</p>
|
<p>Created: @Model.CreatedAt</p>
|
||||||
|
<p><a asp-controller="Account" asp-action="ChangePassword">Change Password</a></p>
|
||||||
|
|||||||
@ -1,27 +1,64 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Member Center</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<header class="border-bottom mb-4">
|
||||||
<a href="/">Home</a>
|
<div class="container py-3 d-flex flex-column gap-3">
|
||||||
<a href="/profile">Profile</a>
|
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
|
||||||
<a href="/admin/tenants">Tenants</a>
|
<div>
|
||||||
<a href="/admin/newsletter-lists">Newsletter Lists</a>
|
<div class="fw-bold">Member Center</div>
|
||||||
<a href="/admin/subscriptions">Subscriptions</a>
|
<div class="text-muted small">Client-first member portal</div>
|
||||||
<a href="/admin/oauth-clients">OAuth Clients</a>
|
</div>
|
||||||
<a href="/admin/audit-logs">Audit Logs</a>
|
<div class="d-flex gap-2 align-items-center">
|
||||||
<a href="/admin/security">Security</a>
|
@if (User.Identity?.IsAuthenticated ?? false)
|
||||||
<a href="/admin/blacklist">Blacklist</a>
|
{
|
||||||
<a href="/account/login">Login</a>
|
<span class="text-muted small">@User.Identity!.Name</span>
|
||||||
<form method="post" action="/account/logout" style="display:inline">
|
<form method="post" asp-area="" asp-controller="Account" asp-action="Logout" class="m-0">
|
||||||
<button type="submit">Logout</button>
|
<button type="submit" class="btn btn-outline-secondary btn-sm">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</nav>
|
}
|
||||||
<main>
|
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()
|
@RenderBody()
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -18,5 +18,179 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin-bottom: 60px;
|
margin: 0;
|
||||||
|
background: #f6f4ef;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #125b50;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
main.container {
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #d1d5db;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-summary-errors,
|
||||||
|
span.field-validation-error {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(13, 71, 64, 0.08), rgba(13, 71, 64, 0) 220px),
|
||||||
|
#f3efe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar {
|
||||||
|
border-bottom: 1px solid #d6d3cb;
|
||||||
|
background: rgba(255, 252, 246, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-eyebrow {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-utility-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-card,
|
||||||
|
.admin-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
border: 1px solid #d6d3cb;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-heading {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-copy,
|
||||||
|
.admin-content-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
border-color: #c8d5d0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-link.is-active {
|
||||||
|
background: #125b50;
|
||||||
|
color: #fff9ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content-header {
|
||||||
|
padding: 0.25rem 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.admin-shell-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user