Refactor code structure for improved readability and maintainability

This commit is contained in:
warrenchen 2026-02-03 18:11:38 +09:00
parent db39a6ac4c
commit f84cfb5beb
145 changed files with 76672 additions and 280 deletions

View File

@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Api", "src\Mem
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Installer", "src\MemberCenter.Installer\MemberCenter.Installer.csproj", "{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Installer", "src\MemberCenter.Installer\MemberCenter.Installer.csproj", "{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Web", "src\MemberCenter.Web\MemberCenter.Web.csproj", "{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -44,6 +46,10 @@ Global
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.Build.0 = Debug|Any CPU {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.ActiveCfg = Release|Any CPU {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.Build.0 = Release|Any CPU {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.Build.0 = Release|Any CPU
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112} {7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112}
@ -51,5 +57,6 @@ Global
{28015B2B-16F2-4DA0-9DA6-D79C94330A4D} = {150D3A20-BF61-4012-BD40-05D408749112} {28015B2B-16F2-4DA0-9DA6-D79C94330A4D} = {150D3A20-BF61-4012-BD40-05D408749112}
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112} {051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112}
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2} = {150D3A20-BF61-4012-BD40-05D408749112} {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2} = {150D3A20-BF61-4012-BD40-05D408749112}
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561} = {150D3A20-BF61-4012-BD40-05D408749112}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -37,26 +37,30 @@
## 5. 資料模型(概念) ## 5. 資料模型(概念)
- tenants - tenants
- id, name, domains, status, created_at - id, name, domains, status, created_at
- users - users (ASP.NET Core Identity)
- id, email, password_hash, email_verified_at, status, created_at - id, user_name, email, password_hash, email_confirmed, lockout, created_at
- oauth_clients - roles / user_roles (Identity)
- id, tenant_id, name, redirect_uris, client_type, created_at - id, name, created_at
- oauth_codes - OpenIddictApplications
- id, client_id, user_id, code_hash, code_challenge, expires_at, consumed_at - id, client_id, client_secret, display_name, permissions, redirect_uris, properties
- oauth_tokens - OpenIddictAuthorizations
- id, user_id, client_id, access_token_hash, refresh_token_hash, expires_at, revoked_at - id, application_id, status, subject, type, scopes
- oidc_id_tokens - OpenIddictTokens
- id, user_id, client_id, id_token_hash, expires_at - id, application_id, authorization_id, subject, type, status, expiration_date
- OpenIddictScopes
- id, name, display_name, resources
- newsletter_lists - newsletter_lists
- id, tenant_id, name, status, created_at - id, tenant_id, name, status, created_at
- newsletter_subscriptions - newsletter_subscriptions
- id, list_id, tenant_id, email, user_id (nullable), status, preferences, created_at - id, list_id, email, user_id (nullable), status, preferences, created_at
- email_verifications - email_verifications
- id, email, tenant_id, token_hash, purpose, expires_at, consumed_at - id, email, tenant_id, token_hash, purpose, expires_at, consumed_at
- unsubscribe_tokens - unsubscribe_tokens
- id, subscription_id, token_hash, expires_at, consumed_at - id, subscription_id, token_hash, expires_at, consumed_at
- audit_logs - audit_logs
- id, actor_type, actor_id, action, payload, created_at - id, actor_type, actor_id, action, payload, created_at
- system_flags
- id, key, value, updated_at
關聯說明: 關聯說明:
- newsletter_subscriptions.email 與 users.email 維持唯一性關聯 - newsletter_subscriptions.email 與 users.email 維持唯一性關聯

View File

@ -33,3 +33,23 @@
- 會員中心 UI 為統一入口(少數情境) - 會員中心 UI 為統一入口(少數情境)
- 其餘皆走 API 與各站自建 UI - 其餘皆走 API 與各站自建 UI
- 會員中心 UI 不承擔行銷內容或寄送 - 會員中心 UI 不承擔行銷內容或寄送
## UI 路徑對應Use Cases
### 會員端(統一 UI
- UC-01 註冊會員: `/account/register`
- UC-02 登入: `/account/login`
- UC-03 登出: `POST /account/logout`
- UC-04 忘記密碼 / 重設密碼: `/account/forgotpassword`, `/account/resetpassword`
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
- UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...`
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
- UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?subscriptionId=...`
- UC-10 會員資料查看: `/profile`
### 管理者端(統一 UI
- UC-11 租戶管理: `/admin/tenants`
- UC-12 OAuth Client 管理: `/admin/oauth-clients`
- UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs`
- UC-16 安全策略設定: `/admin/security`

6
global.json Normal file
View File

@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.417",
"rollForward": "latestFeature"
}
}

View File

@ -1,9 +1,7 @@
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Entities; using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
@ -12,101 +10,58 @@ namespace MemberCenter.Api.Controllers;
[Authorize(Policy = "Admin")] [Authorize(Policy = "Admin")]
public class AdminNewsletterListsController : ControllerBase public class AdminNewsletterListsController : ControllerBase
{ {
private readonly MemberCenterDbContext _dbContext; private readonly INewsletterListService _listService;
public AdminNewsletterListsController(MemberCenterDbContext dbContext) public AdminNewsletterListsController(INewsletterListService listService)
{ {
_dbContext = dbContext; _listService = listService;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var lists = await _dbContext.NewsletterLists.ToListAsync(); var lists = await _listService.ListAsync();
return Ok(lists.Select(l => new return Ok(lists);
{
l.Id,
l.TenantId,
l.Name,
l.Status
}));
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Create([FromBody] NewsletterListRequest request) public async Task<IActionResult> Create([FromBody] NewsletterListRequest request)
{ {
var list = new NewsletterList var list = await _listService.CreateAsync(request.TenantId, request.Name, request.Status);
{ return Created($"/admin/newsletter-lists/{list.Id}", list);
Id = Guid.NewGuid(),
TenantId = request.TenantId,
Name = request.Name,
Status = request.Status
};
_dbContext.NewsletterLists.Add(list);
await _dbContext.SaveChangesAsync();
return Created($"/admin/newsletter-lists/{list.Id}", new
{
list.Id,
list.TenantId,
list.Name,
list.Status
});
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
var list = await _dbContext.NewsletterLists.FindAsync(id); var list = await _listService.GetAsync(id);
if (list is null) if (list is null)
{ {
return NotFound(); return NotFound();
} }
return Ok(new return Ok(list);
{
list.Id,
list.TenantId,
list.Name,
list.Status
});
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] NewsletterListRequest request) public async Task<IActionResult> Update(Guid id, [FromBody] NewsletterListRequest request)
{ {
var list = await _dbContext.NewsletterLists.FindAsync(id); var list = await _listService.UpdateAsync(id, request.TenantId, request.Name, request.Status);
if (list is null) if (list is null)
{ {
return NotFound(); return NotFound();
} }
return Ok(list);
list.TenantId = request.TenantId;
list.Name = request.Name;
list.Status = request.Status;
await _dbContext.SaveChangesAsync();
return Ok(new
{
list.Id,
list.TenantId,
list.Name,
list.Status
});
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var list = await _dbContext.NewsletterLists.FindAsync(id); var deleted = await _listService.DeleteAsync(id);
if (list is null) if (!deleted)
{ {
return NotFound(); return NotFound();
} }
_dbContext.NewsletterLists.Remove(list);
await _dbContext.SaveChangesAsync();
return NoContent(); return NoContent();
} }
} }

View File

@ -1,9 +1,7 @@
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Entities; using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
@ -12,101 +10,58 @@ namespace MemberCenter.Api.Controllers;
[Authorize(Policy = "Admin")] [Authorize(Policy = "Admin")]
public class AdminTenantsController : ControllerBase public class AdminTenantsController : ControllerBase
{ {
private readonly MemberCenterDbContext _dbContext; private readonly ITenantService _tenantService;
public AdminTenantsController(MemberCenterDbContext dbContext) public AdminTenantsController(ITenantService tenantService)
{ {
_dbContext = dbContext; _tenantService = tenantService;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var tenants = await _dbContext.Tenants.ToListAsync(); var tenants = await _tenantService.ListAsync();
return Ok(tenants.Select(t => new return Ok(tenants);
{
t.Id,
t.Name,
t.Domains,
t.Status
}));
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Create([FromBody] TenantRequest request) public async Task<IActionResult> Create([FromBody] TenantRequest request)
{ {
var tenant = new Tenant var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status);
{ return Created($"/admin/tenants/{tenant.Id}", tenant);
Id = Guid.NewGuid(),
Name = request.Name,
Domains = request.Domains,
Status = request.Status
};
_dbContext.Tenants.Add(tenant);
await _dbContext.SaveChangesAsync();
return Created($"/admin/tenants/{tenant.Id}", new
{
tenant.Id,
tenant.Name,
tenant.Domains,
tenant.Status
});
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
var tenant = await _dbContext.Tenants.FindAsync(id); var tenant = await _tenantService.GetAsync(id);
if (tenant is null) if (tenant is null)
{ {
return NotFound(); return NotFound();
} }
return Ok(new return Ok(tenant);
{
tenant.Id,
tenant.Name,
tenant.Domains,
tenant.Status
});
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] TenantRequest request) public async Task<IActionResult> Update(Guid id, [FromBody] TenantRequest request)
{ {
var tenant = await _dbContext.Tenants.FindAsync(id); var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status);
if (tenant is null) if (tenant is null)
{ {
return NotFound(); return NotFound();
} }
return Ok(tenant);
tenant.Name = request.Name;
tenant.Domains = request.Domains;
tenant.Status = request.Status;
await _dbContext.SaveChangesAsync();
return Ok(new
{
tenant.Id,
tenant.Name,
tenant.Domains,
tenant.Status
});
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var tenant = await _dbContext.Tenants.FindAsync(id); var deleted = await _tenantService.DeleteAsync(id);
if (tenant is null) if (!deleted)
{ {
return NotFound(); return NotFound();
} }
_dbContext.Tenants.Remove(tenant);
await _dbContext.SaveChangesAsync();
return NoContent(); return NoContent();
} }
} }

View File

@ -1,12 +1,6 @@
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Constants; using MemberCenter.Application.Abstractions;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
@ -14,141 +8,75 @@ namespace MemberCenter.Api.Controllers;
[Route("newsletter")] [Route("newsletter")]
public class NewsletterController : ControllerBase public class NewsletterController : ControllerBase
{ {
private readonly MemberCenterDbContext _dbContext; private readonly INewsletterService _newsletterService;
public NewsletterController(MemberCenterDbContext dbContext) public NewsletterController(INewsletterService newsletterService)
{ {
_dbContext = dbContext; _newsletterService = newsletterService;
} }
[HttpPost("subscribe")] [HttpPost("subscribe")]
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request) public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
{ {
var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == request.ListId); var result = await _newsletterService.SubscribeAsync(request.ListId, request.Email, request.Preferences);
if (list is null) if (result is null)
{ {
return NotFound("List not found."); return NotFound("List not found.");
} }
var subscription = await _dbContext.NewsletterSubscriptions
.FirstOrDefaultAsync(s => s.ListId == request.ListId && s.Email == request.Email);
if (subscription is null)
{
subscription = new NewsletterSubscription
{
Id = Guid.NewGuid(),
ListId = request.ListId,
Email = request.Email,
Status = SubscriptionStatus.Pending,
Preferences = ToJsonDocument(request.Preferences)
};
_dbContext.NewsletterSubscriptions.Add(subscription);
}
else
{
subscription.Status = SubscriptionStatus.Pending;
subscription.Preferences = ToJsonDocument(request.Preferences);
}
var confirmToken = CreateToken();
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(confirmToken),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
});
await _dbContext.SaveChangesAsync();
return Ok(new return Ok(new
{ {
id = subscription.Id, id = result.Subscription.Id,
list_id = subscription.ListId, list_id = result.Subscription.ListId,
email = subscription.Email, email = result.Subscription.Email,
status = subscription.Status, status = result.Subscription.Status,
created_at = subscription.CreatedAt, created_at = result.Subscription.CreatedAt,
confirm_token = confirmToken confirm_token = result.ConfirmToken
}); });
} }
[HttpGet("confirm")] [HttpGet("confirm")]
public async Task<IActionResult> Confirm([FromQuery] string token) public async Task<IActionResult> Confirm([FromQuery] string token)
{ {
var tokenHash = HashToken(token); var subscription = await _newsletterService.ConfirmAsync(token);
var confirmToken = await _dbContext.UnsubscribeTokens if (subscription is null)
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
if (confirmToken?.Subscription is null)
{ {
return NotFound("Invalid token."); return NotFound("Invalid token.");
} }
if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return BadRequest("Token expired.");
}
confirmToken.Subscription.Status = SubscriptionStatus.Active;
confirmToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return Ok(new return Ok(new
{ {
id = confirmToken.Subscription.Id, id = subscription.Id,
list_id = confirmToken.Subscription.ListId, list_id = subscription.ListId,
email = confirmToken.Subscription.Email, email = subscription.Email,
status = confirmToken.Subscription.Status, status = subscription.Status,
created_at = confirmToken.Subscription.CreatedAt created_at = subscription.CreatedAt
}); });
} }
[HttpPost("unsubscribe")] [HttpPost("unsubscribe")]
public async Task<IActionResult> Unsubscribe([FromBody] UnsubscribeRequest request) public async Task<IActionResult> Unsubscribe([FromBody] UnsubscribeRequest request)
{ {
var tokenHash = HashToken(request.Token); var subscription = await _newsletterService.UnsubscribeAsync(request.Token);
var unsubscribeToken = await _dbContext.UnsubscribeTokens if (subscription is null)
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
if (unsubscribeToken?.Subscription is null)
{ {
return NotFound("Invalid token."); return NotFound("Invalid token.");
} }
unsubscribeToken.Subscription.Status = SubscriptionStatus.Unsubscribed;
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return Ok(new return Ok(new
{ {
id = unsubscribeToken.Subscription.Id, id = subscription.Id,
list_id = unsubscribeToken.Subscription.ListId, list_id = subscription.ListId,
email = unsubscribeToken.Subscription.Email, email = subscription.Email,
status = unsubscribeToken.Subscription.Status, status = subscription.Status,
created_at = unsubscribeToken.Subscription.CreatedAt created_at = subscription.CreatedAt
}); });
} }
[HttpGet("preferences")] [HttpGet("preferences")]
public async Task<IActionResult> Preferences([FromQuery] Guid? subscriptionId, [FromQuery] string? email) public async Task<IActionResult> Preferences([FromQuery] Guid? subscriptionId, [FromQuery] string? email)
{ {
NewsletterSubscription? subscription = null; var subscription = await _newsletterService.GetPreferencesAsync(subscriptionId, email);
if (subscriptionId.HasValue)
{
subscription = await _dbContext.NewsletterSubscriptions.FindAsync(subscriptionId.Value);
}
else if (!string.IsNullOrWhiteSpace(email))
{
subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
if (subscription is null) if (subscription is null)
{ {
return NotFound("Subscription not found."); return NotFound("Subscription not found.");
@ -160,52 +88,26 @@ public class NewsletterController : ControllerBase
list_id = subscription.ListId, list_id = subscription.ListId,
email = subscription.Email, email = subscription.Email,
status = subscription.Status, status = subscription.Status,
preferences = subscription.Preferences.RootElement preferences = subscription.Preferences
}); });
} }
[HttpPost("preferences")] [HttpPost("preferences")]
public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest request) public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest request)
{ {
var subscription = await _dbContext.NewsletterSubscriptions.FindAsync(request.SubscriptionId); var subscription = await _newsletterService.UpdatePreferencesAsync(request.SubscriptionId, request.Preferences);
if (subscription is null) if (subscription is null)
{ {
return NotFound("Subscription not found."); return NotFound("Subscription not found.");
} }
subscription.Preferences = ToJsonDocument(request.Preferences);
await _dbContext.SaveChangesAsync();
return Ok(new return Ok(new
{ {
id = subscription.Id, id = subscription.Id,
list_id = subscription.ListId, list_id = subscription.ListId,
email = subscription.Email, email = subscription.Email,
status = subscription.Status, status = subscription.Status,
preferences = subscription.Preferences.RootElement preferences = subscription.Preferences
}); });
} }
private static string CreateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes);
}
private static string HashToken(string token)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static JsonDocument ToJsonDocument(Dictionary<string, object>? value)
{
if (value is null)
{
return JsonDocument.Parse("{}");
}
return JsonDocument.Parse(JsonSerializer.Serialize(value));
}
} }

View File

@ -1,6 +1,8 @@
using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Services;
using MemberCenter.Application.Abstractions;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
@ -84,6 +86,9 @@ builder.Services.AddAuthorization(options =>
}); });
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
var app = builder.Build(); var app = builder.Build();

View File

@ -9,29 +9,20 @@
} }
}, },
"profiles": { "profiles": {
"http": { "http": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": false,
"applicationUrl": "http://localhost:0", "applicationUrl": "http://localhost:5050",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
}, },
"https": { "IIS Express": {
"commandName": "Project", "commandName": "IISExpress",
"dotnetRunMessages": true, "launchBrowser": true,
"launchBrowser": true, "environmentVariables": {
"applicationUrl": "https://localhost:0;http://localhost:0", "ASPNETCORE_ENVIRONMENT": "Development"
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
} }
} }
} }

View File

@ -0,0 +1,8 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Application.Abstractions;
public interface IAuditLogService
{
Task<IReadOnlyList<AuditLogDto>> ListAsync(int take = 100);
}

View File

@ -0,0 +1,12 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Application.Abstractions;
public interface INewsletterListService
{
Task<IReadOnlyList<NewsletterListDto>> ListAsync();
Task<NewsletterListDto?> GetAsync(Guid id);
Task<NewsletterListDto> CreateAsync(Guid tenantId, string name, string status);
Task<NewsletterListDto?> UpdateAsync(Guid id, Guid tenantId, string name, string status);
Task<bool> DeleteAsync(Guid id);
}

View File

@ -0,0 +1,12 @@
using MemberCenter.Application.Models.Newsletter;
namespace MemberCenter.Application.Abstractions;
public interface INewsletterService
{
Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences);
Task<SubscriptionDto?> ConfirmAsync(string token);
Task<SubscriptionDto?> UnsubscribeAsync(string token);
Task<SubscriptionDto?> GetPreferencesAsync(Guid? subscriptionId, string? email);
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid subscriptionId, Dictionary<string, object> preferences);
}

View File

@ -0,0 +1,9 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Application.Abstractions;
public interface ISecuritySettingsService
{
Task<SecuritySettingsDto> GetAsync();
Task SaveAsync(SecuritySettingsDto settings);
}

View File

@ -0,0 +1,8 @@
using MemberCenter.Application.Models.Newsletter;
namespace MemberCenter.Application.Abstractions;
public interface ISubscriptionAdminService
{
Task<IReadOnlyList<SubscriptionDto>> ListAsync(int take = 200);
}

View File

@ -0,0 +1,12 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Application.Abstractions;
public interface ITenantService
{
Task<IReadOnlyList<TenantDto>> ListAsync();
Task<TenantDto?> GetAsync(Guid id);
Task<TenantDto> CreateAsync(string name, List<string> domains, string status);
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status);
Task<bool> DeleteAsync(Guid id);
}

View File

@ -0,0 +1,8 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record AuditLogDto(
Guid Id,
string ActorType,
Guid? ActorId,
string Action,
DateTimeOffset CreatedAt);

View File

@ -0,0 +1,3 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record NewsletterListDto(Guid Id, Guid TenantId, string Name, string Status);

View File

@ -0,0 +1,5 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record SecuritySettingsDto(
int AccessTokenMinutes,
int RefreshTokenDays);

View File

@ -0,0 +1,3 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record TenantDto(Guid Id, string Name, List<string> Domains, string Status);

View File

@ -0,0 +1,13 @@
using System.Text.Json;
namespace MemberCenter.Application.Models.Newsletter;
public sealed record SubscriptionDto(
Guid Id,
Guid ListId,
string Email,
string Status,
JsonElement Preferences,
DateTimeOffset CreatedAt);
public sealed record PendingSubscriptionResult(SubscriptionDto Subscription, string ConfirmToken);

View File

@ -0,0 +1,28 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Services;
public sealed class AuditLogService : IAuditLogService
{
private readonly MemberCenterDbContext _dbContext;
public AuditLogService(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<IReadOnlyList<AuditLogDto>> ListAsync(int take = 100)
{
var logs = await _dbContext.AuditLogs
.OrderByDescending(l => l.CreatedAt)
.Take(take)
.ToListAsync();
return logs
.Select(l => new AuditLogDto(l.Id, l.ActorType, l.ActorId, l.Action, l.CreatedAt))
.ToList();
}
}

View File

@ -0,0 +1,77 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Services;
public sealed class NewsletterListService : INewsletterListService
{
private readonly MemberCenterDbContext _dbContext;
public NewsletterListService(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<IReadOnlyList<NewsletterListDto>> ListAsync()
{
var lists = await _dbContext.NewsletterLists.ToListAsync();
return lists.Select(MapList).ToList();
}
public async Task<NewsletterListDto?> GetAsync(Guid id)
{
var list = await _dbContext.NewsletterLists.FindAsync(id);
return list is null ? null : MapList(list);
}
public async Task<NewsletterListDto> CreateAsync(Guid tenantId, string name, string status)
{
var list = new NewsletterList
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = name,
Status = status
};
_dbContext.NewsletterLists.Add(list);
await _dbContext.SaveChangesAsync();
return MapList(list);
}
public async Task<NewsletterListDto?> UpdateAsync(Guid id, Guid tenantId, string name, string status)
{
var list = await _dbContext.NewsletterLists.FindAsync(id);
if (list is null)
{
return null;
}
list.TenantId = tenantId;
list.Name = name;
list.Status = status;
await _dbContext.SaveChangesAsync();
return MapList(list);
}
public async Task<bool> DeleteAsync(Guid id)
{
var list = await _dbContext.NewsletterLists.FindAsync(id);
if (list is null)
{
return false;
}
_dbContext.NewsletterLists.Remove(list);
await _dbContext.SaveChangesAsync();
return true;
}
private static NewsletterListDto MapList(NewsletterList list)
{
return new NewsletterListDto(list.Id, list.TenantId, list.Name, list.Status);
}
}

View File

@ -0,0 +1,174 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Newsletter;
using MemberCenter.Domain.Constants;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace MemberCenter.Infrastructure.Services;
public sealed class NewsletterService : INewsletterService
{
private readonly MemberCenterDbContext _dbContext;
public NewsletterService(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
{
var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId);
if (list is null)
{
return null;
}
var subscription = await _dbContext.NewsletterSubscriptions
.FirstOrDefaultAsync(s => s.ListId == listId && s.Email == email);
if (subscription is null)
{
subscription = new NewsletterSubscription
{
Id = Guid.NewGuid(),
ListId = listId,
Email = email,
Status = SubscriptionStatus.Pending,
Preferences = ToJsonDocument(preferences)
};
_dbContext.NewsletterSubscriptions.Add(subscription);
}
else
{
subscription.Status = SubscriptionStatus.Pending;
subscription.Preferences = ToJsonDocument(preferences);
}
var confirmToken = CreateToken();
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
{
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(confirmToken),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
});
await _dbContext.SaveChangesAsync();
return new PendingSubscriptionResult(MapSubscription(subscription), confirmToken);
}
public async Task<SubscriptionDto?> ConfirmAsync(string token)
{
var tokenHash = HashToken(token);
var confirmToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
if (confirmToken?.Subscription is null)
{
return null;
}
if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return null;
}
confirmToken.Subscription.Status = SubscriptionStatus.Active;
confirmToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return MapSubscription(confirmToken.Subscription);
}
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
{
var tokenHash = HashToken(token);
var unsubscribeToken = await _dbContext.UnsubscribeTokens
.Include(t => t.Subscription)
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
if (unsubscribeToken?.Subscription is null)
{
return null;
}
unsubscribeToken.Subscription.Status = SubscriptionStatus.Unsubscribed;
unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return MapSubscription(unsubscribeToken.Subscription);
}
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid? subscriptionId, string? email)
{
NewsletterSubscription? subscription = null;
if (subscriptionId.HasValue)
{
subscription = await _dbContext.NewsletterSubscriptions.FindAsync(subscriptionId.Value);
}
else if (!string.IsNullOrWhiteSpace(email))
{
subscription = await _dbContext.NewsletterSubscriptions
.Where(s => s.Email == email)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
return subscription is null ? null : MapSubscription(subscription);
}
public async Task<SubscriptionDto?> UpdatePreferencesAsync(Guid subscriptionId, Dictionary<string, object> preferences)
{
var subscription = await _dbContext.NewsletterSubscriptions.FindAsync(subscriptionId);
if (subscription is null)
{
return null;
}
subscription.Preferences = ToJsonDocument(preferences);
await _dbContext.SaveChangesAsync();
return MapSubscription(subscription);
}
private static string CreateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes);
}
private static string HashToken(string token)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static JsonDocument ToJsonDocument(Dictionary<string, object>? value)
{
if (value is null)
{
return JsonDocument.Parse("{}");
}
return JsonDocument.Parse(JsonSerializer.Serialize(value));
}
private static SubscriptionDto MapSubscription(NewsletterSubscription subscription)
{
return new SubscriptionDto(
subscription.Id,
subscription.ListId,
subscription.Email,
subscription.Status,
subscription.Preferences.RootElement.Clone(),
subscription.CreatedAt);
}
}

View File

@ -0,0 +1,65 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Services;
public sealed class SecuritySettingsService : ISecuritySettingsService
{
private const string AccessTokenKey = "token_access_minutes";
private const string RefreshTokenKey = "token_refresh_days";
private readonly MemberCenterDbContext _dbContext;
public SecuritySettingsService(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<SecuritySettingsDto> GetAsync()
{
var access = await GetFlagAsync(AccessTokenKey, 60);
var refresh = await GetFlagAsync(RefreshTokenKey, 30);
return new SecuritySettingsDto(access, refresh);
}
public async Task SaveAsync(SecuritySettingsDto settings)
{
await SetFlagAsync(AccessTokenKey, settings.AccessTokenMinutes.ToString());
await SetFlagAsync(RefreshTokenKey, settings.RefreshTokenDays.ToString());
await _dbContext.SaveChangesAsync();
}
private async Task<int> GetFlagAsync(string key, int defaultValue)
{
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
if (flag is null)
{
return defaultValue;
}
return int.TryParse(flag.Value, out var value) ? value : defaultValue;
}
private async Task SetFlagAsync(string key, string value)
{
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
if (flag is null)
{
_dbContext.SystemFlags.Add(new SystemFlag
{
Id = Guid.NewGuid(),
Key = key,
Value = value,
UpdatedAt = DateTimeOffset.UtcNow
});
}
else
{
flag.Value = value;
flag.UpdatedAt = DateTimeOffset.UtcNow;
}
}
}

View File

@ -0,0 +1,35 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Newsletter;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace MemberCenter.Infrastructure.Services;
public sealed class SubscriptionAdminService : ISubscriptionAdminService
{
private readonly MemberCenterDbContext _dbContext;
public SubscriptionAdminService(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<IReadOnlyList<SubscriptionDto>> ListAsync(int take = 200)
{
var subscriptions = await _dbContext.NewsletterSubscriptions
.OrderByDescending(s => s.CreatedAt)
.Take(take)
.ToListAsync();
return subscriptions
.Select(s => new SubscriptionDto(
s.Id,
s.ListId,
s.Email,
s.Status,
s.Preferences.RootElement.Clone(),
s.CreatedAt))
.ToList();
}
}

View File

@ -0,0 +1,77 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Services;
public sealed class TenantService : ITenantService
{
private readonly MemberCenterDbContext _dbContext;
public TenantService(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<IReadOnlyList<TenantDto>> ListAsync()
{
var tenants = await _dbContext.Tenants.ToListAsync();
return tenants.Select(MapTenant).ToList();
}
public async Task<TenantDto?> GetAsync(Guid id)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
return tenant is null ? null : MapTenant(tenant);
}
public async Task<TenantDto> CreateAsync(string name, List<string> domains, string status)
{
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Name = name,
Domains = domains,
Status = status
};
_dbContext.Tenants.Add(tenant);
await _dbContext.SaveChangesAsync();
return MapTenant(tenant);
}
public async Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
if (tenant is null)
{
return null;
}
tenant.Name = name;
tenant.Domains = domains;
tenant.Status = status;
await _dbContext.SaveChangesAsync();
return MapTenant(tenant);
}
public async Task<bool> DeleteAsync(Guid id)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
if (tenant is null)
{
return false;
}
_dbContext.Tenants.Remove(tenant);
await _dbContext.SaveChangesAsync();
return true;
}
private static TenantDto MapTenant(Tenant tenant)
{
return new TenantDto(tenant.Id, tenant.Name, tenant.Domains, tenant.Status);
}
}

View File

@ -0,0 +1,163 @@
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Models.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers;
public class AccountController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AccountController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
[HttpGet]
public IActionResult Login(string? returnUrl = null)
{
return View(new LoginViewModel { ReturnUrl = returnUrl });
}
[HttpPost]
public async Task<IActionResult> Login(LoginViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, false, true);
if (!result.Succeeded)
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
if (!string.IsNullOrWhiteSpace(model.ReturnUrl))
{
return LocalRedirect(model.ReturnUrl);
}
return RedirectToAction("Index", "Home");
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Logout()
{
await _signInManager.SignOutAsync();
return RedirectToAction("Index", "Home");
}
[HttpGet]
public IActionResult Register()
{
return View(new RegisterViewModel());
}
[HttpPost]
public async Task<IActionResult> Register(RegisterViewModel model)
{
if (!ModelState.IsValid)
{
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);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return View(model);
}
return RedirectToAction("Login");
}
[HttpGet]
public IActionResult ForgotPassword()
{
return View(new ForgotPasswordViewModel());
}
[HttpPost]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var user = await _userManager.FindByEmailAsync(model.Email);
if (user is null)
{
return View("ForgotPasswordConfirmation");
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
ViewData["ResetToken"] = token;
ViewData["ResetEmail"] = user.Email;
return View("ForgotPasswordConfirmation");
}
[HttpGet]
public IActionResult ResetPassword(string email, string token)
{
return View(new ResetPasswordViewModel { Email = email, Token = token });
}
[HttpPost]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var user = await _userManager.FindByEmailAsync(model.Email);
if (user is null)
{
return View("ResetPasswordConfirmation");
}
var result = await _userManager.ResetPasswordAsync(user, model.Token, model.NewPassword);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return View(model);
}
return View("ResetPasswordConfirmation");
}
[HttpGet]
public async Task<IActionResult> VerifyEmail(string email, string token)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return View("VerifyEmailResult", false);
}
var result = await _userManager.ConfirmEmailAsync(user, token);
return View("VerifyEmailResult", result.Succeeded);
}
}

View File

@ -0,0 +1,24 @@
using MemberCenter.Application.Abstractions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
[Authorize(Policy = "Admin")]
[Route("admin/audit-logs")]
public class AuditLogsController : Controller
{
private readonly IAuditLogService _auditLogService;
public AuditLogsController(IAuditLogService auditLogService)
{
_auditLogService = auditLogService;
}
[HttpGet("")]
public async Task<IActionResult> Index()
{
var logs = await _auditLogService.ListAsync();
return View(logs);
}
}

View File

@ -0,0 +1,85 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
[Authorize(Policy = "Admin")]
[Route("admin/newsletter-lists")]
public class NewsletterListsController : Controller
{
private readonly INewsletterListService _listService;
public NewsletterListsController(INewsletterListService listService)
{
_listService = listService;
}
[HttpGet("")]
public async Task<IActionResult> Index()
{
var lists = await _listService.ListAsync();
return View(lists);
}
[HttpGet("create")]
public IActionResult Create()
{
return View(new NewsletterListFormViewModel());
}
[HttpPost("create")]
public async Task<IActionResult> Create(NewsletterListFormViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
await _listService.CreateAsync(model.TenantId, model.Name, model.Status);
return RedirectToAction("Index");
}
[HttpGet("edit/{id:guid}")]
public async Task<IActionResult> Edit(Guid id)
{
var list = await _listService.GetAsync(id);
if (list is null)
{
return NotFound();
}
return View(new NewsletterListFormViewModel
{
Id = list.Id,
TenantId = list.TenantId,
Name = list.Name,
Status = list.Status
});
}
[HttpPost("edit/{id:guid}")]
public async Task<IActionResult> Edit(Guid id, NewsletterListFormViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var updated = await _listService.UpdateAsync(id, model.TenantId, model.Name, model.Status);
if (updated is null)
{
return NotFound();
}
return RedirectToAction("Index");
}
[HttpPost("delete/{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await _listService.DeleteAsync(id);
return RedirectToAction("Index");
}
}

View File

@ -0,0 +1,145 @@
using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
namespace MemberCenter.Web.Controllers.Admin;
[Authorize(Policy = "Admin")]
[Route("admin/oauth-clients")]
public class OAuthClientsController : Controller
{
private readonly IOpenIddictApplicationManager _applicationManager;
public OAuthClientsController(IOpenIddictApplicationManager applicationManager)
{
_applicationManager = applicationManager;
}
[HttpGet("")]
public async Task<IActionResult> Index()
{
var results = new List<object>();
await foreach (var application in _applicationManager.ListAsync())
{
results.Add(new
{
id = await _applicationManager.GetIdAsync(application),
name = await _applicationManager.GetDisplayNameAsync(application),
client_id = await _applicationManager.GetClientIdAsync(application),
client_type = await _applicationManager.GetClientTypeAsync(application),
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
});
}
return View(results);
}
[HttpGet("create")]
public IActionResult Create()
{
return View(new OAuthClientFormViewModel());
}
[HttpPost("create")]
public async Task<IActionResult> Create(OAuthClientFormViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Guid.NewGuid().ToString("N"),
DisplayName = model.Name,
ClientType = model.ClientType,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Profile,
"scp:openid"
}
};
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
await _applicationManager.CreateAsync(descriptor);
return RedirectToAction("Index");
}
[HttpGet("edit/{id}")]
public async Task<IActionResult> Edit(string id)
{
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
var redirectUris = await _applicationManager.GetRedirectUrisAsync(app);
var properties = await _applicationManager.GetPropertiesAsync(app);
var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty;
return View(new OAuthClientFormViewModel
{
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty,
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString()))
});
}
[HttpPost("edit/{id}")]
public async Task<IActionResult> Edit(string id, OAuthClientFormViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
var descriptor = new OpenIddictApplicationDescriptor();
await _applicationManager.PopulateAsync(descriptor, app);
descriptor.DisplayName = model.Name;
descriptor.ClientType = model.ClientType;
descriptor.RedirectUris.Clear();
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
await _applicationManager.UpdateAsync(app, descriptor);
return RedirectToAction("Index");
}
[HttpPost("delete/{id}")]
public async Task<IActionResult> Delete(string id)
{
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
await _applicationManager.DeleteAsync(app);
return RedirectToAction("Index");
}
}

View File

@ -0,0 +1,38 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
[Authorize(Policy = "Admin")]
[Route("admin/security")]
public class SecurityController : Controller
{
private readonly ISecuritySettingsService _settingsService;
public SecurityController(ISecuritySettingsService settingsService)
{
_settingsService = settingsService;
}
[HttpGet("")]
public async Task<IActionResult> Index()
{
var settings = await _settingsService.GetAsync();
return View(settings);
}
[HttpPost("")]
public async Task<IActionResult> Save(SecuritySettingsDto model)
{
if (!ModelState.IsValid)
{
return View("Index", model);
}
await _settingsService.SaveAsync(model);
ViewData["Result"] = "Saved";
return View("Index", model);
}
}

View File

@ -0,0 +1,39 @@
using MemberCenter.Application.Abstractions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Text;
namespace MemberCenter.Web.Controllers.Admin;
[Authorize(Policy = "Admin")]
[Route("admin/subscriptions")]
public class SubscriptionsController : Controller
{
private readonly ISubscriptionAdminService _subscriptionService;
public SubscriptionsController(ISubscriptionAdminService subscriptionService)
{
_subscriptionService = subscriptionService;
}
[HttpGet("")]
public async Task<IActionResult> Index()
{
var subscriptions = await _subscriptionService.ListAsync();
return View(subscriptions);
}
[HttpGet("export")]
public async Task<IActionResult> Export()
{
var subscriptions = await _subscriptionService.ListAsync();
var sb = new StringBuilder();
sb.AppendLine("id,list_id,email,status,created_at");
foreach (var s in subscriptions)
{
sb.AppendLine($"{s.Id},{s.ListId},{s.Email},{s.Status},{s.CreatedAt:o}");
}
return File(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "subscriptions.csv");
}
}

View File

@ -0,0 +1,87 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers.Admin;
[Authorize(Policy = "Admin")]
[Route("admin/tenants")]
public class TenantsController : Controller
{
private readonly ITenantService _tenantService;
public TenantsController(ITenantService tenantService)
{
_tenantService = tenantService;
}
[HttpGet("")]
public async Task<IActionResult> Index()
{
var tenants = await _tenantService.ListAsync();
return View(tenants);
}
[HttpGet("create")]
public IActionResult Create()
{
return View(new TenantFormViewModel());
}
[HttpPost("create")]
public async Task<IActionResult> Create(TenantFormViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
await _tenantService.CreateAsync(model.Name, domains, model.Status);
return RedirectToAction("Index");
}
[HttpGet("edit/{id:guid}")]
public async Task<IActionResult> Edit(Guid id)
{
var tenant = await _tenantService.GetAsync(id);
if (tenant is null)
{
return NotFound();
}
return View(new TenantFormViewModel
{
Id = tenant.Id,
Name = tenant.Name,
Domains = string.Join(",", tenant.Domains),
Status = tenant.Status
});
}
[HttpPost("edit/{id:guid}")]
public async Task<IActionResult> Edit(Guid id, TenantFormViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var domains = model.Domains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
var updated = await _tenantService.UpdateAsync(id, model.Name, domains, model.Status);
if (updated is null)
{
return NotFound();
}
return RedirectToAction("Index");
}
[HttpPost("delete/{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await _tenantService.DeleteAsync(id);
return RedirectToAction("Index");
}
}

View File

@ -0,0 +1,31 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using MemberCenter.Web.Models;
namespace MemberCenter.Web.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(ILogger<HomeController> logger)
{
_logger = logger;
}
public IActionResult Index()
{
return View();
}
public IActionResult Privacy()
{
return View();
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}

View File

@ -0,0 +1,102 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Web.Models.Newsletter;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace MemberCenter.Web.Controllers;
public class NewsletterController : Controller
{
private readonly INewsletterService _newsletterService;
public NewsletterController(INewsletterService newsletterService)
{
_newsletterService = newsletterService;
}
[HttpGet]
public IActionResult Confirm(string token)
{
return View(new ConfirmViewModel { Token = token });
}
[HttpPost]
public async Task<IActionResult> Confirm(ConfirmViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var subscription = await _newsletterService.ConfirmAsync(model.Token);
if (subscription is null)
{
ViewData["Result"] = "Invalid or expired token.";
return View("ConfirmResult");
}
ViewData["Result"] = "Subscription activated.";
return View("ConfirmResult");
}
[HttpGet]
public IActionResult Unsubscribe(string token)
{
return View(new UnsubscribeViewModel { Token = token });
}
[HttpPost]
public async Task<IActionResult> Unsubscribe(UnsubscribeViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var subscription = await _newsletterService.UnsubscribeAsync(model.Token);
if (subscription is null)
{
ViewData["Result"] = "Invalid token.";
return View("UnsubscribeResult");
}
ViewData["Result"] = "Subscription canceled.";
return View("UnsubscribeResult");
}
[HttpGet]
public IActionResult Preferences(Guid subscriptionId)
{
return View(new PreferencesViewModel { SubscriptionId = subscriptionId });
}
[HttpPost]
public async Task<IActionResult> Preferences(PreferencesViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
Dictionary<string, object>? preferences = null;
try
{
preferences = JsonSerializer.Deserialize<Dictionary<string, object>>(model.PreferencesJson);
}
catch
{
ModelState.AddModelError(string.Empty, "Invalid JSON.");
return View(model);
}
var subscription = await _newsletterService.UpdatePreferencesAsync(model.SubscriptionId, preferences ?? new Dictionary<string, object>());
if (subscription is null)
{
ViewData["Result"] = "Subscription not found.";
return View("PreferencesResult");
}
ViewData["Result"] = "Preferences updated.";
return View("PreferencesResult");
}
}

View File

@ -0,0 +1,29 @@
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Controllers;
[Authorize]
public class ProfileController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
public ProfileController(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
[HttpGet]
public async Task<IActionResult> Index()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
}
return View(user);
}
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\MemberCenter.Application\MemberCenter.Application.csproj" />
<ProjectReference Include="..\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj" />
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Account;
public sealed class ForgotPasswordViewModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
}

View File

@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Account;
public sealed class LoginViewModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;
public string? ReturnUrl { get; set; }
}

View File

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

View File

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

View File

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Admin;
public sealed class NewsletterListFormViewModel
{
public Guid? Id { get; set; }
[Required]
public Guid TenantId { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string Status { get; set; } = "active";
}

View File

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Admin;
public sealed class OAuthClientFormViewModel
{
[Required]
public Guid TenantId { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string ClientType { get; set; } = "public";
public string RedirectUris { get; set; } = string.Empty;
}

View File

@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Admin;
public sealed class TenantFormViewModel
{
public Guid? Id { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
public string Domains { get; set; } = string.Empty;
[Required]
public string Status { get; set; } = "active";
}

View File

@ -0,0 +1,8 @@
namespace MemberCenter.Web.Models;
public class ErrorViewModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
}

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Newsletter;
public sealed class ConfirmViewModel
{
[Required]
public string Token { get; set; } = string.Empty;
}

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Newsletter;
public sealed class PreferencesViewModel
{
[Required]
public Guid SubscriptionId { get; set; }
public string PreferencesJson { get; set; } = "{}";
}

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Newsletter;
public sealed class UnsubscribeViewModel
{
[Required]
public string Token { get; set; } = string.Empty;
}

View File

@ -0,0 +1,79 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
EnvLoader.LoadDotEnvIfDevelopment();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Default")
?? Environment.GetEnvironmentVariable("ConnectionStrings__Default")
?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
options.UseOpenIddict();
});
builder.Services
.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/account/login";
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy => policy.RequireRole("admin"));
});
builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<MemberCenterDbContext>()
.ReplaceDefaultEntities<Guid>();
});
builder.Services.AddControllersWithViews();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();

View File

@ -0,0 +1,14 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5060",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,9 @@
@model MemberCenter.Web.Models.Account.ForgotPasswordViewModel
<h1>Forgot Password</h1>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<button type="submit">Send Reset Token</button>
</form>

View File

@ -0,0 +1,16 @@
@{
var token = ViewData["ResetToken"] as string;
var email = ViewData["ResetEmail"] as string;
}
<h1>Reset Token</h1>
@if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(email))
{
<p>Use this token for reset:</p>
<p><strong>@token</strong></p>
<p><a href="/account/resetpassword?email=@email&token=@token">Go to reset</a></p>
}
else
{
<p>If the email exists, a reset token has been generated.</p>
}

View File

@ -0,0 +1,15 @@
@model MemberCenter.Web.Models.Account.LoginViewModel
<h1>Login</h1>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label>Password</label>
<input asp-for="Password" type="password" />
<span asp-validation-for="Password"></span>
<input type="hidden" asp-for="ReturnUrl" />
<button type="submit">Login</button>
</form>

View File

@ -0,0 +1,18 @@
@model MemberCenter.Web.Models.Account.RegisterViewModel
<h1>Register</h1>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label>Password</label>
<input asp-for="Password" type="password" />
<span asp-validation-for="Password"></span>
<label>Confirm Password</label>
<input asp-for="ConfirmPassword" type="password" />
<span asp-validation-for="ConfirmPassword"></span>
<button type="submit">Register</button>
</form>

View File

@ -0,0 +1,22 @@
@model MemberCenter.Web.Models.Account.ResetPasswordViewModel
<h1>Reset Password</h1>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label>Token</label>
<input asp-for="Token" />
<span asp-validation-for="Token"></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">Reset</button>
</form>

View File

@ -0,0 +1,2 @@
<h1>Password Reset</h1>
<p>Password reset completed.</p>

View File

@ -0,0 +1,11 @@
@model bool
<h1>Email Verification</h1>
@if (Model)
{
<p>Email verified.</p>
}
else
{
<p>Invalid verification link.</p>
}

View File

@ -0,0 +1,18 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.AuditLogDto>
<h1>Audit Logs</h1>
<table>
<thead>
<tr><th>Action</th><th>Actor</th><th>Time</th></tr>
</thead>
<tbody>
@foreach (var log in Model)
{
<tr>
<td>@log.Action</td>
<td>@log.ActorType @log.ActorId</td>
<td>@log.CreatedAt</td>
</tr>
}
</tbody>
</table>

View File

@ -0,0 +1,18 @@
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
<h1>Create Newsletter List</h1>
<form method="post">
<label>Tenant Id</label>
<input asp-for="TenantId" />
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Status</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,18 @@
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
<h1>Edit Newsletter List</h1>
<form method="post">
<label>Tenant Id</label>
<input asp-for="TenantId" />
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Status</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,25 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.NewsletterListDto>
<h1>Newsletter Lists</h1>
<p><a href="/admin/newsletter-lists/create">Create</a></p>
<table>
<thead>
<tr><th>Name</th><th>Tenant</th><th>Status</th><th></th></tr>
</thead>
<tbody>
@foreach (var list in Model)
{
<tr>
<td>@list.Name</td>
<td>@list.TenantId</td>
<td>@list.Status</td>
<td>
<a href="/admin/newsletter-lists/edit/@list.Id">Edit</a>
<form method="post" action="/admin/newsletter-lists/delete/@list.Id" style="display:inline">
<button type="submit">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>

View File

@ -0,0 +1,21 @@
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
<h1>Create OAuth Client</h1>
<form method="post">
<label>Tenant Id</label>
<input asp-for="TenantId" />
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Client Type</label>
<input asp-for="ClientType" />
<span asp-validation-for="ClientType"></span>
<label>Redirect URIs (comma-separated)</label>
<input asp-for="RedirectUris" />
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,21 @@
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
<h1>Edit OAuth Client</h1>
<form method="post">
<label>Tenant Id</label>
<input asp-for="TenantId" />
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Client Type</label>
<input asp-for="ClientType" />
<span asp-validation-for="ClientType"></span>
<label>Redirect URIs (comma-separated)</label>
<input asp-for="RedirectUris" />
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,29 @@
@model IReadOnlyList<object>
<h1>OAuth Clients</h1>
<p><a href="/admin/oauth-clients/create">Create</a></p>
<table>
<thead>
<tr><th>Name</th><th>Client Id</th><th>Type</th><th></th></tr>
</thead>
<tbody>
@foreach (var item in Model)
{
var name = (string)item.GetType().GetProperty("name")!.GetValue(item)!;
var clientId = (string)item.GetType().GetProperty("client_id")!.GetValue(item)!;
var clientType = (string)item.GetType().GetProperty("client_type")!.GetValue(item)!;
var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!;
<tr>
<td>@name</td>
<td>@clientId</td>
<td>@clientType</td>
<td>
<a href="/admin/oauth-clients/edit/@id">Edit</a>
<form method="post" action="/admin/oauth-clients/delete/@id" style="display:inline">
<button type="submit">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>

View File

@ -0,0 +1,16 @@
@model MemberCenter.Application.Models.Admin.SecuritySettingsDto
<h1>Security Settings</h1>
@if (ViewData["Result"] is not null)
{
<p>@ViewData["Result"]</p>
}
<form method="post">
<label>Access token minutes</label>
<input name="AccessTokenMinutes" value="@Model.AccessTokenMinutes" />
<label>Refresh token days</label>
<input name="RefreshTokenDays" value="@Model.RefreshTokenDays" />
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,20 @@
@model IReadOnlyList<MemberCenter.Application.Models.Newsletter.SubscriptionDto>
<h1>Subscriptions</h1>
<p><a href="/admin/subscriptions/export">Export CSV</a></p>
<table>
<thead>
<tr><th>Email</th><th>List</th><th>Status</th><th>Created</th></tr>
</thead>
<tbody>
@foreach (var sub in Model)
{
<tr>
<td>@sub.Email</td>
<td>@sub.ListId</td>
<td>@sub.Status</td>
<td>@sub.CreatedAt</td>
</tr>
}
</tbody>
</table>

View File

@ -0,0 +1,17 @@
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
<h1>Create Tenant</h1>
<form method="post">
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Domains (comma-separated)</label>
<input asp-for="Domains" />
<label>Status</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,17 @@
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
<h1>Edit Tenant</h1>
<form method="post">
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Domains (comma-separated)</label>
<input asp-for="Domains" />
<label>Status</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,25 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto>
<h1>Tenants</h1>
<p><a href="/admin/tenants/create">Create</a></p>
<table>
<thead>
<tr><th>Name</th><th>Domains</th><th>Status</th><th></th></tr>
</thead>
<tbody>
@foreach (var tenant in Model)
{
<tr>
<td>@tenant.Name</td>
<td>@string.Join(",", tenant.Domains)</td>
<td>@tenant.Status</td>
<td>
<a href="/admin/tenants/edit/@tenant.Id">Edit</a>
<form method="post" action="/admin/tenants/delete/@tenant.Id" style="display:inline">
<button type="submit">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>

View File

@ -0,0 +1,2 @@
<h1>Member Center</h1>
<p>Welcome.</p>

View File

@ -0,0 +1,6 @@
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@ -0,0 +1,9 @@
@model MemberCenter.Web.Models.Newsletter.ConfirmViewModel
<h1>Confirm Subscription</h1>
<form method="post">
<label>Token</label>
<input asp-for="Token" />
<span asp-validation-for="Token"></span>
<button type="submit">Confirm</button>
</form>

View File

@ -0,0 +1,2 @@
<h1>Subscription</h1>
<p>@(ViewData["Result"] ?? "Done")</p>

View File

@ -0,0 +1,10 @@
@model MemberCenter.Web.Models.Newsletter.PreferencesViewModel
<h1>Preferences</h1>
<form method="post">
<input asp-for="SubscriptionId" type="hidden" />
<label>Preferences JSON</label>
<textarea asp-for="PreferencesJson" rows="6"></textarea>
<span asp-validation-for="PreferencesJson"></span>
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,2 @@
<h1>Preferences</h1>
<p>@(ViewData["Result"] ?? "Done")</p>

View File

@ -0,0 +1,9 @@
@model MemberCenter.Web.Models.Newsletter.UnsubscribeViewModel
<h1>Unsubscribe</h1>
<form method="post">
<label>Token</label>
<input asp-for="Token" />
<span asp-validation-for="Token"></span>
<button type="submit">Unsubscribe</button>
</form>

View File

@ -0,0 +1,2 @@
<h1>Unsubscribe</h1>
<p>@(ViewData["Result"] ?? "Done")</p>

View File

@ -0,0 +1,6 @@
@model MemberCenter.Infrastructure.Identity.ApplicationUser
<h1>Profile</h1>
<p>Email: @Model.Email</p>
<p>Verified: @Model.EmailConfirmed</p>
<p>Created: @Model.CreatedAt</p>

View File

@ -0,0 +1,25 @@
@model ErrorViewModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Member Center</title>
</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="/account/login">Login</a>
<form method="post" action="/account/logout" style="display:inline">
<button type="submit">Logout</button>
</form>
</nav>
<main>
@RenderBody()
</main>
</body>
</html>

View File

@ -0,0 +1,48 @@
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}

View File

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

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,22 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.

View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,427 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

Some files were not shown because too many files have changed in this diff Show More